mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
Merge pull request #29027 from open-webui/dev
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
0.11.1
This commit is contained in:
commit
d3e8bf3405
548 changed files with 38913 additions and 16283 deletions
|
|
@ -16,6 +16,12 @@ CORS_ALLOW_ORIGIN='*'
|
|||
# Set to false to keep memory tools enabled without adding memory context to the system context.
|
||||
ENABLE_MEMORY_SYSTEM_CONTEXT=true
|
||||
|
||||
# Set to true to add compact row/column stats to parsed CSV retrieval context.
|
||||
ENABLE_RAG_CSV_SUMMARY=false
|
||||
|
||||
# Set to true to preserve backing file records, storage blobs, and per-file vectors when files are removed from knowledge bases.
|
||||
ENABLE_KNOWLEDGE_FILE_RETENTION=false
|
||||
|
||||
# Set to false to disable workspace Tools and Functions.
|
||||
ENABLE_PLUGINS=true
|
||||
|
||||
|
|
|
|||
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -2,7 +2,6 @@ name: Bug Report
|
|||
description: Create a detailed bug report to help us improve Open WebUI.
|
||||
title: 'issue: '
|
||||
labels: ['bug', 'triage']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
@ -35,6 +34,8 @@ body:
|
|||
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!).
|
||||
required: true
|
||||
- label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source.
|
||||
required: true
|
||||
- label: I am using the latest version of Open WebUI.
|
||||
required: true
|
||||
|
||||
|
|
@ -55,7 +56,7 @@ body:
|
|||
id: open-webui-version
|
||||
attributes:
|
||||
label: Open WebUI Version
|
||||
description: Specify the version (e.g., v0.6.26)
|
||||
description: Specify the version (e.g., v0.11.0)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ body:
|
|||
id: ollama-version
|
||||
attributes:
|
||||
label: Ollama Version (if applicable)
|
||||
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
|
||||
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
|
@ -71,7 +72,7 @@ body:
|
|||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
|
||||
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ body:
|
|||
id: browser
|
||||
attributes:
|
||||
label: Browser (if applicable)
|
||||
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
|
||||
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
|
@ -138,11 +139,11 @@ body:
|
|||
|
||||
placeholder: |
|
||||
Example (include every detail):
|
||||
1. Start with a clean Ubuntu 22.04 install.
|
||||
2. Install Docker v24.0.5 and start the service.
|
||||
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 115.0 in incognito mode.
|
||||
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".
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -36,6 +36,8 @@ body:
|
|||
options:
|
||||
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
|
||||
required: true
|
||||
- label: I have checked whether this request is already implemented on the `dev` branch or in the latest source.
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: feature-scope
|
||||
|
|
|
|||
42
.github/pull_request_template.md
vendored
42
.github/pull_request_template.md
vendored
|
|
@ -1,30 +1,55 @@
|
|||
<!--
|
||||
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
|
||||
Do not open a PR as the first step.
|
||||
For real, reproducible bugs, start with a well-described Issue that explains the problem, why it matters, and what outcome you are looking for.
|
||||
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active Discussion.
|
||||
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
|
||||
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
|
||||
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
|
||||
We may close unsolicited PRs without review.
|
||||
Contributors with a history of successful merged PRs may be given more latitude.
|
||||
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
-->
|
||||
|
||||
# Pull Request Checklist
|
||||
|
||||
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
|
||||
### Do not open a pull request as the first step.
|
||||
|
||||
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
|
||||
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.
|
||||
|
||||
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed.
|
||||
|
||||
If you 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.
|
||||
|
||||
Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude.
|
||||
|
||||
<!--
|
||||
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
-->
|
||||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
**Before submitting, make sure you've checked and filled out the following:**
|
||||
|
||||
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
|
||||
- [ ] **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 tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
|
||||
- [ ] **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.
|
||||
|
|
@ -40,9 +65,6 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
|
|||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
- **style**: Formatting changes (whitespace, semicolons, etc.)
|
||||
- **test**: Test additions or corrections
|
||||
- **WIP**: Work in progress
|
||||
|
||||
# Changelog Entry
|
||||
|
||||
|
|
@ -86,7 +108,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
|
|||
|
||||
### Screenshots or Videos
|
||||
|
||||
- [Attach relevant screenshots or videos demonstrating the changes]
|
||||
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
|
|
|
|||
106
.github/workflows/issue-label.yaml
vendored
106
.github/workflows/issue-label.yaml
vendored
|
|
@ -2,7 +2,7 @@ name: Issue Labeler
|
|||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
|
@ -22,22 +22,118 @@ jobs:
|
|||
return;
|
||||
}
|
||||
|
||||
const isEdit = context.payload.action === 'edited';
|
||||
const titleWasEdited = Boolean(context.payload.changes?.title);
|
||||
|
||||
if (isEdit && !titleWasEdited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = issue.title ?? '';
|
||||
const body = issue.body ?? '';
|
||||
|
||||
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
|
||||
const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title);
|
||||
const bugLikeTitle = /^\s*(\[\s*(bug|issue|fix)\b[^\]]*\]|(bug|issue|fix)\s*[:/\-])/i.test(title);
|
||||
|
||||
// API/CLI-created issues that reproduce the bug report form structure.
|
||||
// Only headings distinctive to the bug form (both are required fields there) —
|
||||
// generic headings like "Expected Behavior" also appear in freeform feature requests.
|
||||
const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body);
|
||||
|
||||
if (bugLikeTitle || bugFormBody) {
|
||||
await github.rest.issues.addLabels({
|
||||
if (!bugLikeTitle && !bugFormBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['bug']
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const bugLabelWasRemoved = events.some(
|
||||
(event) => event.event === 'unlabeled' && event.label?.name === 'bug'
|
||||
);
|
||||
|
||||
if (bugLabelWasRemoved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['bug']
|
||||
});
|
||||
|
||||
label-feature-requests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add "enhancement" label to unlabeled feature requests
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
|
||||
if (issue.labels.some((label) => label.name === 'enhancement')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A human (or the bug form) already classified this as a bug;
|
||||
// do not stack a second, contradictory classification on it.
|
||||
if (issue.labels.some((label) => label.name === 'bug')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = context.payload.action === 'edited';
|
||||
const titleWasEdited = Boolean(context.payload.changes?.title);
|
||||
|
||||
if (isEdit && !titleWasEdited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = issue.title ?? '';
|
||||
const body = issue.body ?? '';
|
||||
|
||||
// Feature requests: "feat: ...", "feature: ...", "feature request: ...",
|
||||
// "enhancement: ...", "enh: ...", "[Feature Request] ..." — the feature
|
||||
// request form titles every submission "feat: ", so form submissions are
|
||||
// covered by the same pattern.
|
||||
const featureLikeTitle =
|
||||
/^\s*(\[\s*(feat|feature|enhancement|enh)\b[^\]]*\]|(feat|feature( request)?|enhancement|enh)\s*[:/\-])/i.test(
|
||||
title
|
||||
);
|
||||
|
||||
// API/CLI-created issues that reproduce the feature request form structure.
|
||||
// Only headings distinctive to that form.
|
||||
const featureFormBody = /###\s*(Proposed Solution|Alternatives Considered)/i.test(body);
|
||||
|
||||
if (!featureLikeTitle && !featureFormBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const enhancementLabelWasRemoved = events.some(
|
||||
(event) => event.event === 'unlabeled' && event.label?.name === 'enhancement'
|
||||
);
|
||||
|
||||
if (enhancementLabelWasRemoved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['enhancement']
|
||||
});
|
||||
|
|
|
|||
316
CHANGELOG.md
316
CHANGELOG.md
|
|
@ -5,6 +5,322 @@ 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.1] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- 🚦 **Human in the loop tool approval.** Where an administrator has turned it on, you can switch a conversation from letting tools run freely to being asked first, so a model that wants to use a tool stops and waits for you to allow or deny it, one call at a time in a saved conversation, by button or by keyboard shortcut, with your choice remembered for this conversation and for future ones, switching back to running freely releasing anything already waiting, and automations, channel replies, and temporary chats unaffected. [Commit](https://github.com/open-webui/open-webui/commit/7d99b2716a0472b2100b3a71825d8eb3fcbbe877), [Commit](https://github.com/open-webui/open-webui/commit/ec36972c2b5a8d48713f1a240b0ed305e535b4cc), [Commit](https://github.com/open-webui/open-webui/commit/653562d660398c32a9a193450bbbee300d7195c3), [Commit](https://github.com/open-webui/open-webui/commit/fa94a5ab2431edba064150651a14dd4992ef29e8), [Commit](https://github.com/open-webui/open-webui/commit/55c202e841e76cd69679206cd0ecb4a65b039ea4), [Commit](https://github.com/open-webui/open-webui/commit/30f82788bc75e6965da3766e3bf43840dd971eed), [Commit](https://github.com/open-webui/open-webui/commit/bbfdbd59f29e246db1d8b5c2401bc7b11aa32c99), [Commit](https://github.com/open-webui/open-webui/commit/7fc5fa1ff3f6cd6f0efcbf847064ca8d21620ba6), [Commit](https://github.com/open-webui/open-webui/commit/3eb65f47151af4fb4ccfaf33d6e77154546e7259), [Commit](https://github.com/open-webui/open-webui/commit/62fc436999ad32e82d1405ac14d1f03e0f0341ec)
|
||||
- 🙋♂️ **Models that can ask you a question.** A new built-in tool lets a model pause and put up to three multiple-choice questions to you before continuing, with room to type your own answer instead, and the question survives a reload in a saved conversation, so you can come back and answer it later rather than losing the conversation. [Commit](https://github.com/open-webui/open-webui/commit/4465f52a3eb521854cf190f91b0ea7cf3fa21830), [Commit](https://github.com/open-webui/open-webui/commit/133549a87ee371577453d897c2db2d8071223f27), [Commit](https://github.com/open-webui/open-webui/commit/b018feb7419e68314378b3cdd8b7b1b389400a98), [Commit](https://github.com/open-webui/open-webui/commit/083e35144152d6a301bed01aa6d898db3871ddcf), [Commit](https://github.com/open-webui/open-webui/commit/256cce505be8ddd4930e8cb2536faea718d3f789), [Commit](https://github.com/open-webui/open-webui/commit/14e4d72d9a21a10196e8e6efb04180cd9104b8e6), [Commit](https://github.com/open-webui/open-webui/commit/57bd08304e45f707768a898de9f50894929008dd), [Commit](https://github.com/open-webui/open-webui/commit/d9014b3483d4c6e8d99e50395afcdc538bcf05cd)
|
||||
- 🖇 **Agents can now display terminal files directly.** A model can now show a file it made in a terminal directly in its reply, with a preview and a download button, instead of describing a path that led nowhere when clicked, and a new interface setting chooses whether these open in the reply or in the files pane. [Commit](https://github.com/open-webui/open-webui/commit/78f48a21eef330c0b78c33f2b5fc2169b084997c), [Commit](https://github.com/open-webui/open-webui/commit/e623c02acc70c4ee5d7f2eb2f32d9b7f39287663), [Commit](https://github.com/open-webui/open-webui/commit/f64c0c87e8d1bfdbe060ea5e5a3dee24c0323657), [#27650](https://github.com/open-webui/open-webui/issues/27650)
|
||||
- 📶 **Streaming rebuilt from the ground up.** A reply now streams as small pieces of new text instead of resending the whole message so far with every update, so the data sent over a reply grows with its length rather than with its length squared, which on a server with many people chatting at once means far less processor time spent encoding, passing, and decoding those updates, far less load and memory on the shared cache that carries them between instances, and far less work in your browser, which no longer takes in the whole reply again and redraws the parts of it that have not changed on every update, cutting the data sent and the server work spent appending to a message by up to 1000x on a very long reply, and a reply still in progress is now kept aside on the server, so reopening the conversation after a refresh picks it up where it is rather than showing a blank message, on deployments backed by Redis. [Commit](https://github.com/open-webui/open-webui/commit/a1579a01ff43cacb357269707d36267ad35e01d6), [Commit](https://github.com/open-webui/open-webui/commit/c755ef60c6bd47ea25306bd898d9a6d1bd8e871d), [Commit](https://github.com/open-webui/open-webui/commit/d02b6a21fc02fb073782e25356968cfee3c45c36), [Commit](https://github.com/open-webui/open-webui/commit/3e186abdd91edee9e97e43c9b345714643a84cf5)
|
||||
- 🪵 **Much faster throughout.** Hundreds of places across the application no longer assemble detailed log text that is switched off and thrown away unread, so sending messages, uploading and indexing files, running searches, signing in, and loading admin pages all get that time back, with the largest gains on busy servers, in long conversations, and on chats that draw from a large knowledge base. [#27834](https://github.com/open-webui/open-webui/pull/27834), [#27837](https://github.com/open-webui/open-webui/pull/27837)
|
||||
- 🚀 **Faster model list lookups.** Title generation, tag suggestions, autocomplete, and other background steps of a chat turn now fetch the model list in one go, which keeps other people's responses flowing on busy Redis-backed instances with many models. [#27821](https://github.com/open-webui/open-webui/pull/27821)
|
||||
- 🛰️ **Cheaper log export.** Deployments that export their logs to a telemetry collector, with "ENABLE_OTEL" and "ENABLE_OTEL_LOGS" both set, now prepare each exported line once instead of twice, which matters more than it used to now that log text is only assembled when something will actually read it. [#27840](https://github.com/open-webui/open-webui/pull/27840)
|
||||
- 📇 **Faster permission checks on large instances.** Working out which groups you belong to is now a direct lookup rather than a scan of every membership on the server, so chats and the admin user list stay quick as an organization grows. [#27822](https://github.com/open-webui/open-webui/pull/27822)
|
||||
- ⚙️ **Much faster JSON handling.** Saving and opening chats, reading settings, returning results from built-in tools, streaming replies, signing in and signing up, working out your permissions, and reading stored chunk details during knowledge base searches on Valkey and Oracle vector storage are all handled much faster across the application when the "ENABLE_ORJSON" option is turned on. [Commit](https://github.com/open-webui/open-webui/commit/bb0f898b431d5aa45efa7805956657ed9c3dd78d), [#28396](https://github.com/open-webui/open-webui/pull/28396), [#27841](https://github.com/open-webui/open-webui/pull/27841), [#27807](https://github.com/open-webui/open-webui/pull/27807), [#27805](https://github.com/open-webui/open-webui/pull/27805), [#27813](https://github.com/open-webui/open-webui/pull/27813)
|
||||
- 📤 **Much faster outbound requests.** Conversations and embedding batches sent to Ollama and Anthropic models are packaged for delivery much faster, which is most noticeable in long chats when the "ENABLE_ORJSON" option is turned on. [#27811](https://github.com/open-webui/open-webui/pull/27811), [#27810](https://github.com/open-webui/open-webui/pull/27810)
|
||||
- 🐍 **Much faster code interpreter output.** Printed output and generated images from code run in chat appear much faster when the "ENABLE_ORJSON" option is turned on. [#27812](https://github.com/open-webui/open-webui/pull/27812)
|
||||
- 🪶 **Lighter page loads.** Several small requests the interface makes on every page load, along with a few administrative ones, no longer set up database access they never used, which took several times longer than the rest of the request put together. [#28178](https://github.com/open-webui/open-webui/pull/28178)
|
||||
- ♻️ **One less read per message.** Sending a message no longer loads the whole conversation from the database twice over, which mattered most in long chats where that record is largest. [#28809](https://github.com/open-webui/open-webui/pull/28809)
|
||||
- 🏁 **Faster skills on large instances.** Opening the skills list, or sending a message that uses one, no longer checks every skill on the instance one at a time, so both are far quicker where many skills exist and most of them are not yours. [#28798](https://github.com/open-webui/open-webui/pull/28798)
|
||||
- 🩻 **Faster tools on large instances.** Listing or exporting tools no longer checks every tool on the instance one at a time, so the integrations menu and the tools workspace open faster where many exist. [Commit](https://github.com/open-webui/open-webui/commit/4807866a1cf47340f1b5ea76fded95f8114305f9)
|
||||
- 🧊 **Faster file access checks.** Checking whether you may reach a file no longer walks every workspace model you can see looking for it, so opening a folder of files, downloading one, or retrieving from one is much quicker on instances with many models. [#28802](https://github.com/open-webui/open-webui/pull/28802)
|
||||
- 🧱 **Faster folder listings.** Listing your folders now works out your group memberships once for the whole listing rather than again for every item in every folder. [#28810](https://github.com/open-webui/open-webui/pull/28810)
|
||||
- 🧼 **Less work per update in a long chat.** Each update saved while a reply streams no longer re-examines the entire conversation, only the part being added, so the cost of an update stops growing with the length of the chat. [#28820](https://github.com/open-webui/open-webui/pull/28820)
|
||||
- 🗃 **Cheaper attaching of sources and files to a reply.** Adding a source, file, or embedded item to a reply now reads just that one field rather than rebuilding the whole conversation to find it, which on a two hundred message chat is around 3.1 ms per item down to 0.65 ms, and no longer grows with the length of the conversation. [Commit](https://github.com/open-webui/open-webui/commit/536b9edec00547d5b84ef2e6ea0f929c054c1333), [Commit](https://github.com/open-webui/open-webui/commit/9dff5e93277aa1a2236d702e7e2f55dbb35ea7fa)
|
||||
- 🚏 **Faster workspace model lookups.** Working out which workspace models you may edit no longer loads every model on the instance and discards most of them, which also speeds up exporting models and the file access checks that relied on it. [#28795](https://github.com/open-webui/open-webui/pull/28795)
|
||||
- 📮 **Faster handing off a streaming reply.** Passing a reply in progress between instances now writes it once rather than converting it back and forth and scanning it for characters that only matter elsewhere, which on a large non-English conversation took most of the time spent on each write. [#28833](https://github.com/open-webui/open-webui/pull/28833)
|
||||
- 🥵 **Constant load on an idle instance.** An instance sitting idle no longer works through every chat you have once a second looking for timers that are due, which on a large history kept about a quarter of a processor core busy doing nothing and could exhaust memory until the application was killed. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745)
|
||||
- 📍 **Sidebar folders fetched once.** Refreshing the sidebar now asks for your folders once rather than three times, on page load and on every action that refreshes it. [#28662](https://github.com/open-webui/open-webui/pull/28662), [#28661](https://github.com/open-webui/open-webui/issues/28661)
|
||||
- 💤 **Far fewer writes just from being signed in.** Recording that someone is online now writes at most once a minute for each person rather than on every single request, where an open tab alone caused two write transactions a minute before anyone touched anything. [#28177](https://github.com/open-webui/open-webui/pull/28177), [#28165](https://github.com/open-webui/open-webui/issues/28165)
|
||||
- 🛰 **Less overhead on every request.** The layers each request passes through before it is handled are now one instead of five, which also removes a quarter of that cost from every piece of a streamed reply on instances that set security headers. [Commit](https://github.com/open-webui/open-webui/commit/b96d2b12dae5e953e520bec03f74e9b85b955dc7), [#28171](https://github.com/open-webui/open-webui/issues/28171)
|
||||
- 🎏 **Turning off compression of live updates.** A new "UVICORN_WS_PER_MESSAGE_DEFLATE" setting stops the server compressing every live update it sends, which costs processor time on each one for almost no saving now that a reply streams as small pieces; compression stays on unless it is turned off. [#28613](https://github.com/open-webui/open-webui/pull/28613)
|
||||
- 🌡 **Faster chat list and unread counts.** Opening the sidebar, and the unread markers on folders, no longer read through your whole chat history to produce a short list, which on an instance with 15000 chats took 2 to 4 seconds. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745)
|
||||
- 🥁 **Long replies no longer slow as they grow.** A long reply is no longer re-examined from the beginning for reasoning and code blocks on every piece that arrives, so the work stops growing with the length of the reply, which on a long reply is around 190x less time spent on it. [#28861](https://github.com/open-webui/open-webui/pull/28861)
|
||||
- 💽 **Faster saving of long chats.** A chat is now written to the database in one go rather than one message at a time, so saving a long conversation is much quicker and puts far less strain on the database, and saving one where nothing has changed writes nothing at all. [#28806](https://github.com/open-webui/open-webui/pull/28806)
|
||||
- 📦 **Faster loading of shared folders.** Folders shared with you now load in a couple of queries rather than one for each folder and each owner, so the list appears sooner for anyone with many of them. [#28804](https://github.com/open-webui/open-webui/pull/28804)
|
||||
- ⚡ **Uninterrupted chat during knowledge search.** Responses now keep streaming for everyone on the server while knowledge base searches run, instead of pausing until each search finishes. [#27824](https://github.com/open-webui/open-webui/pull/27824)
|
||||
- 🔍 **Smarter chat search.** Searching your chats now finds conversations containing all of your words in any order rather than only the exact phrase you typed, with exact matches still listed first, and the preview snippet points at whichever word it found. [Commit](https://github.com/open-webui/open-webui/commit/0800c21c64c64810f24c2ec88cca5e36daebb10e)
|
||||
- ⌨️ **Model switching from the message box.** Typing "/model" now tells you which model you are on, switching to another by name with "/model" followed by its id, or opening the model picker straight from the slash menu without reaching for the mouse. [Commit](https://github.com/open-webui/open-webui/commit/29eeda9f9abeaedf77176908cb036ace7f175e76), [Commit](https://github.com/open-webui/open-webui/commit/9c7ce154e79a3baea3a8223ab1e5b1371ce80dbb)
|
||||
- 📎 **Sending while attachments upload.** Sending a message before its files have finished uploading now queues it and sends it automatically once they are ready, instead of refusing with an error, and each queued message shows the progress of its attachments. [Commit](https://github.com/open-webui/open-webui/commit/6c4d0ace163a89aba6e8cd2aa9fef185167ebe0f), [Commit](https://github.com/open-webui/open-webui/commit/c1c07cbe0f847e4dd04e21f2a6c5f12cdc0fc7ee), [#28381](https://github.com/open-webui/open-webui/pull/28381), [#28380](https://github.com/open-webui/open-webui/issues/28380)
|
||||
- 📖 **Opening a document at the right page.** A model showing you a PDF, Word document, or slide deck from a terminal can now open it at a particular page or slide, so a reply that cites something on page 76 can put that page in front of you. [Commit](https://github.com/open-webui/open-webui/commit/fd8cc2ba4a226ccbf51e114c71f156676eabb2d9), [Commit](https://github.com/open-webui/open-webui/commit/cf4ac9c8db67031627f196fae894764367fba5b4), [Commit](https://github.com/open-webui/open-webui/commit/6cb2449ab70ed6aa5124fcd2b1291b48527af4cd)
|
||||
- 💼 **Attachments that go straight to a terminal.** A terminal connection can now be set to receive files attached in chat into its own working directory rather than into the conversation, which also means files can be attached while using a model that cannot read them itself. [Commit](https://github.com/open-webui/open-webui/commit/8a42aa53e826d5f25d6443ca95e0173e3cf034cf), [Commit](https://github.com/open-webui/open-webui/commit/d7d935275a77fb185586a5130cd37acf1175ca0a), [Commit](https://github.com/open-webui/open-webui/commit/1b3b9375bb4de0f360782dd2f9d8c7c2903b8baa), [Commit](https://github.com/open-webui/open-webui/commit/d17f06a23501e5c39cf98d9c9642178da25343fb)
|
||||
- 🔦 **Searching files in the terminal browser.** The file browser now has a search box that finds files by name and by what is inside them, and opening a result takes you to the matching line. [Commit](https://github.com/open-webui/open-webui/commit/7abe11346a1cc309da2c3fcec2f61faaeb47b96a)
|
||||
- 🌲 **Browsing files as a tree.** The terminal file browser now expands folders in place rather than only navigating into them, remembers what you had open, offers a right-click menu, can show hidden files, sorts by size, expands a folder you hover over while dragging something onto it, and moves a whole selection in one go when you drop it. [Commit](https://github.com/open-webui/open-webui/commit/7dfbdd221ac0f7a839dd049e3e82b6a84c316860), [Commit](https://github.com/open-webui/open-webui/commit/516cf1a9a6154336182475b9df2d333b1579516d)
|
||||
- 🧰 **Managing models on more servers.** Administrators can now download, load, and unload models on llama.cpp and LM Studio connections from the manage models dialog, remove them on llama.cpp, and start a download straight from the model picker's search box, alongside the Ollama support that was already there. [Commit](https://github.com/open-webui/open-webui/commit/85c3d0ae2fad58ffc2b92b1733a6c8bc7ab471db), [Commit](https://github.com/open-webui/open-webui/commit/8260d527ee97372a207ce9bd9c6dab4909061281), [Commit](https://github.com/open-webui/open-webui/commit/25802c048e6123fa602182949ad2a9f349e1e863), [Commit](https://github.com/open-webui/open-webui/commit/31c1ffd55a018a74c7e53a35fb9f9bbe33774b6f), [#28766](https://github.com/open-webui/open-webui/pull/28766)
|
||||
- 📢 **Automations that post to a channel.** An automation can now be pointed at a channel instead of a chat, so its scheduled run appears as a message there for everyone to see, chosen from a new destination picker that also covers folders. [Commit](https://github.com/open-webui/open-webui/commit/2649e3305c49cb37101c112ae10ffd8beacb5885)
|
||||
- 🙋 **Mentioning people in a channel.** Typing an at sign in a channel now lists that channel's own members first, before everyone else on the server, so the people you are likely to mean are at the top. [Commit](https://github.com/open-webui/open-webui/commit/ba885d0026ad3cce70c7cc36d1d6f59775264a12), [Commit](https://github.com/open-webui/open-webui/commit/dcff244f9e87e615ee0a3f345f7f801c5ebe370d), [#28883](https://github.com/open-webui/open-webui/issues/28883)
|
||||
- 🔗 **Attaching any link.** Pasting a link into a chat or a knowledge base now works out what is behind it, downloading a document or image as a real attachment rather than treating everything as a web page to be read as text. [Commit](https://github.com/open-webui/open-webui/commit/8fbfd14a8b3fe7236883594fd18760076e4de0d5)
|
||||
- 🔎 **Searching tools and skills in chat.** The integrations menu now has a search box for tools and for skills, so a long list can be narrowed by name instead of scrolled through. [#26709](https://github.com/open-webui/open-webui/issues/26709), [Commit](https://github.com/open-webui/open-webui/commit/954613944b317a78d5929456ae1388543aa052c8), [#28807](https://github.com/open-webui/open-webui/pull/28807), [#28812](https://github.com/open-webui/open-webui/pull/28812)
|
||||
- 🧭 **More from the message box.** The slash menu now offers settings and, in a new chat, a toggle for temporary chat, alongside the commands that were already there. [Commit](https://github.com/open-webui/open-webui/commit/a5ea732c1e3d0d1f70a7e4b6a20c30c336114784)
|
||||
- 🚨 **Being told when a file fails to process.** A file that cannot be processed for a knowledge base now raises a notification naming the file and what went wrong, and keeps that reason on the file, instead of quietly being marked as failed. [#27666](https://github.com/open-webui/open-webui/pull/27666), [#6311](https://github.com/open-webui/open-webui/issues/6311), [Commit](https://github.com/open-webui/open-webui/commit/1a376ac17fa0c3f957656a997b6f8ffacb1f6f30)
|
||||
- 🔬 **Zoom controls on previewed images.** An image opened in the file browser now has zoom in, zoom out, and a reset button showing the current zoom, and pinching, scrolling, and holding a modifier key while scrolling now zoom and pan as they do elsewhere. [Commit](https://github.com/open-webui/open-webui/commit/2befa8f796266e92fa55861bb8eaa81639ff4053), [Commit](https://github.com/open-webui/open-webui/commit/bfb68feea766e8d5408fb6e278be56cbca0c4afe), [Commit](https://github.com/open-webui/open-webui/commit/ec9bf5a64f9f718e472123350ec83bce1b064884), [Commit](https://github.com/open-webui/open-webui/commit/467be93e6d7e4a31358f9c75ee67bfac1200c0a1)
|
||||
- 🗂️ **Recognisable file icons.** The terminal file browser now marks each file with an icon for its type, so code, images, archives, documents, and configuration files can be told apart at a glance instead of sharing one generic page icon. [Commit](https://github.com/open-webui/open-webui/commit/60feca71a6a77b7d1eef1c172a61624d5fe48150), [Commit](https://github.com/open-webui/open-webui/commit/8d25ad00e2fc5f0a328a2098c2e73fb00f2ca934), [Commit](https://github.com/open-webui/open-webui/commit/c1f914a6268580f7722825390ac8a204743a4520)
|
||||
- 📽️ **Truer PowerPoint previews.** Slide previews now render tables, charts, connectors, gradients, theme colours, bullets, fonts, and text alignment far closer to the original, and the viewer lets you move between slides with the arrow keys or the scroll wheel while the thumbnail strip follows along. [Commit](https://github.com/open-webui/open-webui/commit/8dd23f74c9a59fdfbdaefb4a403ef79bdd3dfed9), [Commit](https://github.com/open-webui/open-webui/commit/048c06399363baac4046717129f072172ee90805), [Commit](https://github.com/open-webui/open-webui/commit/c93c6d6fc48ee8870eec349cb4c5af88c9435cfb), [Commit](https://github.com/open-webui/open-webui/commit/76583749edb966c0239ce203a84c1ee8d8c7faee), [Commit](https://github.com/open-webui/open-webui/commit/b3a5fd3875dc3c29a11eb5435438e55685cad639), [Commit](https://github.com/open-webui/open-webui/commit/794671a9883d5e067c407067f324ffa25ed38e3f), [Commit](https://github.com/open-webui/open-webui/commit/f96b717566b2084bd5fe70fb622ab66b5a643c0d), [Commit](https://github.com/open-webui/open-webui/commit/b7292890ccea794750651abbcde01cda62551e1b), [Commit](https://github.com/open-webui/open-webui/commit/31d08d592c4ee46a7ce827d6ffdf8e3cf16bf2ac)
|
||||
- 📄 **Faithful Word document previews.** Word documents now open as proper pages with headers, footers, footnotes, and embedded images intact, and can be zoomed, rather than being flattened into plain formatted text. [Commit](https://github.com/open-webui/open-webui/commit/ff7467b4c593f1e775311c088c63340d0cc1e4a2), [Commit](https://github.com/open-webui/open-webui/commit/060648f939447e2a9de390d05221ca896c50eeed)
|
||||
- 🗝 **Deleting your API key.** An API key can now be revoked outright from your account settings, where the only way to retire one was to replace it with a new one. [#28874](https://github.com/open-webui/open-webui/issues/28874), [Commit](https://github.com/open-webui/open-webui/commit/b30b11d4c975b8ff9a6c6eb9e39fbb310aed1072)
|
||||
- 🎛️ **Settings for the task model.** Administrators can now set the generation parameters used for background work such as titles, tags, follow-ups, search queries, and conversation summaries, either from the admin panel or through "TASK_MODEL_PARAMS", instead of those requests always using a fixed token limit that could cut a summary short. [#27604](https://github.com/open-webui/open-webui/issues/27604), [Commit](https://github.com/open-webui/open-webui/commit/f0bfcd40976dfb1e1876f86b39d6a659d894d15e), [Commit](https://github.com/open-webui/open-webui/commit/865c80c1600ecc2e606bef4e90540ce305b4b9de)
|
||||
- 🎚️ **Default interface settings for everyone.** Administrators can now set system-wide defaults for the interface options in Settings, either from the admin panel or through "DEFAULT_INTERFACE_SETTINGS", with each person's own choices still taking precedence and anything left untouched shown as inherited and kept in step with later changes to the defaults. [Commit](https://github.com/open-webui/open-webui/commit/37f2548155efdd5cd8114ceb9b57c7864de4bef2), [Commit](https://github.com/open-webui/open-webui/commit/13346c5f1621b014e2fcaabd41375d6f7035dd0a), [Commit](https://github.com/open-webui/open-webui/commit/90a0e61cef119154e89d900d6921123b599a0389), [Commit](https://github.com/open-webui/open-webui/commit/eeaf1a1df01525e4b6d4eb73060b32a9a75ebfc6), [Commit](https://github.com/open-webui/open-webui/commit/d4461bd6f39936460a989e8bd50097de604b4b00), [Commit](https://github.com/open-webui/open-webui/commit/b4d3b27caf1587b783a46268c769132eb9211c80), [Commit](https://github.com/open-webui/open-webui/commit/407c40f72cdadcd9f57538d9ce38070662ce42c7), [Commit](https://github.com/open-webui/open-webui/commit/724d2ebbf1bd2fc90f27a2e5bc571d40afc3db4c), [Commit](https://github.com/open-webui/open-webui/commit/1674e5a9ef835696f0963cdbcf477c8b92ce0b8a)
|
||||
- 🔠 **Interface scaling throughout.** The UI Scale setting and your browser's own text size now resize the whole interface consistently, including the sidebar, menus, dialogs, and file browser, rather than leaving parts of it fixed. [Commit](https://github.com/open-webui/open-webui/commit/30d08a42f8b0c32cc64dccd81203cc760651584a), [Commit](https://github.com/open-webui/open-webui/commit/b5f86e6a433b519e30a5a6f1b0a5bf985939c9a8), [Commit](https://github.com/open-webui/open-webui/commit/ec03e8814403422a6ab3dc62f4239fd7af439361), [Commit](https://github.com/open-webui/open-webui/commit/72a909fd2f3ac8d67554f6c85bc1723934454e2d)
|
||||
- 🏷️ **Named writing blocks.** When a model wraps a draft such as an email in a writing block, the block is now titled with its subject and shows the recipient beside it, rather than every block reading simply as Writing. [#28280](https://github.com/open-webui/open-webui/pull/28280), [#28198](https://github.com/open-webui/open-webui/issues/28198)
|
||||
- 🤝 **Files for delegated tasks.** A task handed to a sub-agent can now carry the attachments it needs, so an image or document from your conversation reaches the sub-agent instead of arriving as a file reference it cannot open and may answer about anyway. [Commit](https://github.com/open-webui/open-webui/commit/5ec16e76e6402980b39c26923b8ee26278f0b243), [#28213](https://github.com/open-webui/open-webui/issues/28213)
|
||||
- 📟 **Terminal availability and scope.** Administrators can now decide for each managed terminal whether it appears in chats and in automations at all, and whether everyone shares a single workspace or each chat or automation gets its own, with per chat terminals waiting until the conversation has been saved. [Commit](https://github.com/open-webui/open-webui/commit/009999f3636b1a3451f8fe55232e6ab132a64e66)
|
||||
- 🔒 **Read-only files in the terminal browser.** Files and folders you are not allowed to change are now labelled read-only, with uploading, editing, renaming, moving, and deleting turned off for them rather than failing at the moment you try. [Commit](https://github.com/open-webui/open-webui/commit/2dadc5435af77b0638af69200e3af1b9654417b6)
|
||||
- 🔐 **Terminals that use your own login.** Managed terminals configured for session authentication now authenticate the terminal connection with your own token, where it previously sent no credentials at all. [Commit](https://github.com/open-webui/open-webui/commit/2dadc5435af77b0638af69200e3af1b9654417b6)
|
||||
- 🎟 **Setting up a tool server that uses OAuth.** Adding one is now easier to get right: the connection dialog can authorize the account from the dialog itself, the check button tests the sign-in details rather than reporting a connection failure that was never going to succeed without them, and it is now labelled for what it does rather than suggesting it verifies the whole connection. [Commit](https://github.com/open-webui/open-webui/commit/f822605b3563c57030aa200492f68572cadcc4da), [#28552](https://github.com/open-webui/open-webui/issues/28552)
|
||||
- 🪤 **Control over what embedded pages may do.** Two new interface settings decide whether pages shown inside a chat, such as an artifact or an HTML preview, may run scripts and start downloads. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf), [#28924](https://github.com/open-webui/open-webui/issues/28924), [Commit](https://github.com/open-webui/open-webui/commit/842c1f9d677c0d9940cccdd18e34add13f746a68)
|
||||
- 🗄 **Keeping files removed from a knowledge base.** A new "ENABLE_KNOWLEDGE_FILE_RETENTION" setting keeps the stored file and its search data when a file is taken out of a knowledge base, rather than deleting them. [Commit](https://github.com/open-webui/open-webui/commit/363ad352fec9553469852d111bc0506b896504a6)
|
||||
- 🧾 **CSV shape in retrieval.** Turning on "ENABLE_RAG_CSV_SUMMARY" adds a short line naming the row count, data row count, column count, and column names of a CSV file to what the model sees, giving it the shape of the table alongside its contents. [Commit](https://github.com/open-webui/open-webui/commit/1b72899f246ba46ab8f7cd6aee2d56ef69217d82)
|
||||
- 🔭 **OpenSERP in the search settings.** OpenSERP can now be picked as the web search engine in the admin panel, with a field for its address, rather than only being configurable through the environment. [#27594](https://github.com/open-webui/open-webui/pull/27594), [#27592](https://github.com/open-webui/open-webui/issues/27592)
|
||||
- 🪧 **Profile changes from single sign-on.** A name, email address, or picture updated from an identity provider at sign-in now raises an event naming what changed, and the rest of the session uses the updated record rather than a stale copy. [Commit](https://github.com/open-webui/open-webui/commit/927ce0eae67af1b4a856d3e82fba0601c3b40d02)
|
||||
- 📯 **Group changes from single sign-on.** Group memberships added or removed when someone signs in through an identity provider, and groups created automatically along the way, now raise the same events as the equivalent change made by an administrator or over directory sync. [#27657](https://github.com/open-webui/open-webui/pull/27657)
|
||||
- 🔔 **Sign-in and sign-out events for single sign-on.** Signing in through an identity provider now raises the same login event that signing in with a password does, and signing out says which provider the session came from, so a function can set up or tidy up an account in another system when someone arrives or leaves. [#27619](https://github.com/open-webui/open-webui/pull/27619), [#27613](https://github.com/open-webui/open-webui/issues/27613)
|
||||
- 🪛 **Naming background worker threads.** A new "THREAD_POOL_THREAD_NAME_PREFIX" setting labels the threads that background work runs in, so they can be told apart when reading a profile or a thread dump. [Commit](https://github.com/open-webui/open-webui/commit/4ec6ee14418edd04eaba9e34bd5868453f61df40)
|
||||
- 📙 **OpenDocument files in a temporary chat.** A text document, spreadsheet, or presentation from an office suite that uses the OpenDocument format now has its text read out in the browser when attached to a temporary chat, where the model was handed the raw archive and answered that it could not read the file. [Commit](https://github.com/open-webui/open-webui/commit/9e7c9360b744c878ae0c38aa1caf3886f27b07ab), [#28906](https://github.com/open-webui/open-webui/discussions/28906)
|
||||
- 🌍 **Pointing Tavily somewhere else.** A new "TAVILY_API_BASE_URL" setting sends Tavily searches and page fetches to a different address, for instances that reach the internet only through a gateway of their own or that use a compatible service. [Commit](https://github.com/open-webui/open-webui/commit/98ee2bdfd3e90faec6bfe8e7ebf5803159f379e3), [#28701](https://github.com/open-webui/open-webui/issues/28701)
|
||||
- 🪟 **Honest OAuth settings.** When single sign-on settings come from the environment rather than being saved in the application, the admin panel now shows them as read-only with a note naming the setting that controls this, instead of accepting edits that were silently discarded on the next restart. [#28276](https://github.com/open-webui/open-webui/pull/28276)
|
||||
- 📏 **Widening the chat controls pane.** The controls pane can now be dragged as wide as you like, where it stopped at a fixed limit regardless of screen size. [Commit](https://github.com/open-webui/open-webui/commit/0fb542b3764cefecf2366607a4685288051d1b46)
|
||||
- 📱 **Smoother sidebar on mobile.** The sidebar now follows your finger as you swipe it open or closed, responds to a quick flick, dims the page behind it as it moves, and gives every chat row a menu button you can reach without a hover you cannot perform on a touchscreen. [Commit](https://github.com/open-webui/open-webui/commit/b20bcdbba72707e3b0cf2b9a6a5f3168b464ba3c), [Commit](https://github.com/open-webui/open-webui/commit/178ccb30e1253ea727fe5ccbb5cc3ca3832dc70b), [Commit](https://github.com/open-webui/open-webui/commit/943294df9a03d45e2708b330e876967af3463282), [Commit](https://github.com/open-webui/open-webui/commit/d6679082e5c6b0b54ca00a99a88e66a53a30c7d9), [Commit](https://github.com/open-webui/open-webui/commit/d8ae7ed40551362922925d9d6e47ba65d3658cfd), [Commit](https://github.com/open-webui/open-webui/commit/be4afd75452361ead376d9977cf2ca8cb93e55eb), [Commit](https://github.com/open-webui/open-webui/commit/ab41dcc487d1517f7c8c5d0b02a02cdaadedb03d)
|
||||
- 🚪 **Sidebar that stays put.** Opening and closing the sidebar is now a smooth transition that keeps your chat list loaded, instead of rebuilding the list each time. [Commit](https://github.com/open-webui/open-webui/commit/3c010951db2cc349466658d3b52234cc22dad327), [Commit](https://github.com/open-webui/open-webui/commit/8edab5020eaa5d47b12a572eb632a891aa29440a), [Commit](https://github.com/open-webui/open-webui/commit/3e9b075954b7fc9d492a7ec832550b10b0bf80ed), [Commit](https://github.com/open-webui/open-webui/commit/3793b0c886f57630dc31320d3c0257c933c6eca1), [Commit](https://github.com/open-webui/open-webui/commit/0b4b7ae5ff3c0a17b58e8e85a5fddf190e3bda14)
|
||||
- 👁️ **Turning off chat previews.** A new setting under Settings and Interface lets you switch off the preview card that appears when you hover a chat in the sidebar, useful for a quieter sidebar, for sharing your screen, or on a slow connection. [#27632](https://github.com/open-webui/open-webui/pull/27632), [#27639](https://github.com/open-webui/open-webui/issues/27639)
|
||||
- ☑️ **Checkboxes beside their labels.** In the model editor and the admin model defaults, each capability, feature, and tool checkbox now sits directly in front of its own label instead of at the far edge of its column, where it could look like it belonged to the next one, and the label itself can be clicked to toggle it. [#27788](https://github.com/open-webui/open-webui/pull/27788), [#27771](https://github.com/open-webui/open-webui/issues/27771), [Commit](https://github.com/open-webui/open-webui/commit/1b39ff352a2fa3b57bd7815f44c50daf96937a14), [Commit](https://github.com/open-webui/open-webui/commit/0f821398ca9c9ddd8da521b5f9dcf103030202e8)
|
||||
- ✍️ **Typing cursor while responding.** A blinking cursor now marks where the reply is being written, from the moment you send your message until generation finishes, in place of the previous loading placeholder. [Commit](https://github.com/open-webui/open-webui/commit/cbb3aade2b4e901c22aa9a30530634721c80b078)
|
||||
- ✒️ **Underlined text.** Underlined text now appears underlined in a reply instead of showing the markup around it, and underlining is kept when you edit in a rich text box rather than being dropped. [Commit](https://github.com/open-webui/open-webui/commit/11db926a7b471e9477595786ef451a916174cc18), [#26904](https://github.com/open-webui/open-webui/issues/26904)
|
||||
- 📥 **Adding group members from a file.** Administrators can now add many people to a group at once by uploading a CSV of names and email addresses, with a template to download and a message naming any row whose address does not match an account. [Commit](https://github.com/open-webui/open-webui/commit/f3f7659da754b51c216b17b2d6def4d59071d78f)
|
||||
- 📑 **Apache Tika 4 support.** Administrators extracting document text with Tika can now choose which server version they run, from Admin Settings under Documents or through "TIKA_SERVER_VERSION", where only Tika 3 was understood before. [Commit](https://github.com/open-webui/open-webui/commit/170ad0595d9440113721eb06375a2fc99aefaad1), [#28939](https://github.com/open-webui/open-webui/issues/28939)
|
||||
- 💓 **Tunable heartbeat for live updates.** A new "WEBSOCKET_HEARTBEAT_INTERVAL" setting controls how often each open tab checks in with the server, where it was fixed at 30 seconds, so a large deployment can cut background traffic that no one asked for. [Commit](https://github.com/open-webui/open-webui/commit/3c1017f6c3ffc7194f074f2cfd2ea5f49943c575), [#28166](https://github.com/open-webui/open-webui/issues/28166)
|
||||
- ⌛ **Expiring abandoned reply state.** A new "REDIS_RESPONSE_STREAM_TTL" setting expires the saved state of a reply that never finished, so a server killed mid-answer no longer leaves that data behind for good. [Commit](https://github.com/open-webui/open-webui/commit/176fa462128d5298492db29c67080a4e1afc2642)
|
||||
- 🖨️ **File and image detail parts on API requests.** A request sent to the OpenAI-compatible endpoint carrying an image detail level or a file part in its message content now forwards both to providers that use the Responses API, where they were dropped, while documents attached inside Open WebUI are unaffected because those still go through knowledge retrieval. [Commit](https://github.com/open-webui/open-webui/commit/ca4e07a40b4ff11989a25d84b517d3d0049f93ae)
|
||||
- 🧺 **Leaner stored document metadata.** Bulky extraction details such as page layouts, tables and detected languages are no longer kept alongside a document in the vector store, and a new "RAG_METADATA_MAX_VALUE_CHARS" setting drops any remaining oversized value, falling back to the configured upload size limit so a document that expands enormously while being read cannot exhaust a server's memory. [Commit](https://github.com/open-webui/open-webui/commit/278e97589e71d119b887d5bca9d6ae32912d1dff), [Commit](https://github.com/open-webui/open-webui/commit/e3a7a64d82ab2dd06c681ce85027367ccc8234d4), [#29025](https://github.com/open-webui/open-webui/pull/29025)
|
||||
- 💨 **No filter work on installs without filters.** A completed message on an install with no filter functions and no pipeline filters, which is the default, no longer rebuilds the whole conversation and ships it to the browser as an event nothing acts on. [Commit](https://github.com/open-webui/open-webui/commit/28f2965934f5b6fba0e0c38a15b2af6ee790819f)
|
||||
- 📂 **Opening a file in a knowledge base.** A file listed in a knowledge base can now be opened and read straight from that list, where the name was shown but nothing happened when it was clicked. [Commit](https://github.com/open-webui/open-webui/commit/20f35d157bc7d535091ce6a90b517c23d26486c0), [#28086](https://github.com/open-webui/open-webui/issues/28086)
|
||||
- 🧵 **Cheaper saving of a reply as it streams.** The resume snapshot taken on every piece of a streamed reply no longer rebuilds the whole answer each time, so the cost of a save stops growing with the length of the reply. [#28821](https://github.com/open-webui/open-webui/pull/28821)
|
||||
- 📀 **Less repeated work setting up built-in tools.** Every chat request no longer rebuilds a fresh copy of each built-in tool's definition from scratch, which was paid once per tool on every message. [#28860](https://github.com/open-webui/open-webui/pull/28860)
|
||||
- 🕹️ **Control over what a terminal port preview may reach.** A new interface setting decides whether a previewed port runs with access to same-origin browser APIs, so you can lock a preview out of them on installs where previews serve content you do not fully trust. [Commit](https://github.com/open-webui/open-webui/commit/54d7a223707f03172efbb9e754db6e69709956d0)
|
||||
- ♿ **Improved UI accessibility.** A closed sidebar is no longer reachable by keyboard or announced by screen readers, the tool call blocks in a response can now be expanded with the keyboard, the buttons that normally appear on hover, such as message actions, file removal, and chat menus, now appear when you reach them with the keyboard as well, whatever you have tabbed to is marked with a clear outline throughout the application, and the rows in the integrations menu now tell a screen reader whether each tool or feature is switched on. [Commit](https://github.com/open-webui/open-webui/commit/48a5696042b414f6511911afa72b3289ad2797b9), [Commit](https://github.com/open-webui/open-webui/commit/bd250a0e2431f8c2c7e2f4a34c5327f21fc77d4b), [Commit](https://github.com/open-webui/open-webui/commit/c8f8fa451a60974dfa4ebaf7cd6163ef30c45fd6), [Commit](https://github.com/open-webui/open-webui/commit/29541cbb52659a8a6ee22d255f14e6d2f168a09b), [Commit](https://github.com/open-webui/open-webui/commit/ac0368b4abe4c880f715b236ee0024826b2e3e7a), [Commit](https://github.com/open-webui/open-webui/commit/c086b80313fe0f5ce60831a0d150f695646c66e4), [#27667](https://github.com/open-webui/open-webui/pull/27667), [#17150](https://github.com/open-webui/open-webui/issues/17150)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Faroese was added, and translations for Slovenian, Hungarian, Finnish, Korean, Portuguese (Brazil), Catalan, and French were enhanced and expanded.
|
||||
|
||||
### 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)
|
||||
- 🛂 **Knowledge search reaching past what you may read.** Searching knowledge bases now applies the list of collections you are allowed to open, where that restriction was handed to the vector store and silently discarded, so results could include material from knowledge bases you have no access to. [Commit](https://github.com/open-webui/open-webui/commit/1d6d4e6e6647e1d403438ede7bd9ba20bc4cc8f6)
|
||||
- 💣 **Documents that unpack far beyond their size.** A Word, Excel, PowerPoint, OpenDocument or EPUB file that expands to far more than it stores is now rejected before it is read, where one could previously be used to exhaust a server's memory. [Commit](https://github.com/open-webui/open-webui/commit/2a0274a0a039dbe0a1ad4d24003b085aae7b896b)
|
||||
- ✂️ **Long replies cut off partway.** A single oversized piece of a streamed reply, such as a long reasoning trace or a turn carrying many tool calls, no longer ends the answer early with a misleading error about byte counts, which affected every default installation. [#28114](https://github.com/open-webui/open-webui/pull/28114), [#25664](https://github.com/open-webui/open-webui/issues/25664)
|
||||
- 🚧 **Web address checks that could be skipped.** The fetchable-address test and the operator's web fetch filter list now run on every outgoing request, where a proxied or already-open connection could bypass them and a filter entry written as an address range silently matched nothing at all. [#27823](https://github.com/open-webui/open-webui/pull/27823)
|
||||
- 🧑💻 **Code execution reachable through a tag in a reply.** On installs using native function calling, the older path that runs code found inside a tag in the model's reply is no longer active alongside the built-in tool, so code execution happens only through an explicit tool call. [#29024](https://github.com/open-webui/open-webui/pull/29024)
|
||||
- ⌚ **Recurrence rules that could tie up the server.** How often an automation repeats is now taken from the rule the scheduler actually parsed rather than from the text of the rule, so a crafted rule can no longer disagree with what gets scheduled and walk the server through an unbounded run of occurrences, and a rule carrying a time zone on its start date now schedules instead of erroring. [Commit](https://github.com/open-webui/open-webui/commit/067114c28038b77045a3a1983e2e2719b1a32ad8)
|
||||
- 🗝️ **Changing a password now ends other sessions.** Changing your password, or an administrator resetting it for you, now stops every device that was already signed in, where they had stayed signed in on the old password until their session expired on its own, up to four weeks by default; the device making the change is signed out too and asked to sign in again, and this requires Redis, without which nothing can be revoked and a warning is now logged saying so. [#28725](https://github.com/open-webui/open-webui/pull/28725), [#28647](https://github.com/open-webui/open-webui/discussions/28647)
|
||||
- 🧬 **Workspace models shadowing a real one.** Someone without administrator rights can no longer create, import, or edit a workspace model so that it takes over the identity of a model served by a connected provider, where doing so would have changed what everyone else got when they picked that model. [Commit](https://github.com/open-webui/open-webui/commit/ea55d38793014a4e3cd5a4046816fe22e69e9739)
|
||||
- 🌳 **Folders disappearing when moved into themselves.** Moving a folder inside one of its own subfolders is now refused, where it was accepted and made that folder and everything in it vanish from the sidebar with no way to bring it back, while leaving the server walking the loop endlessly and querying the database as it went, which could exhaust a worker and its memory; any folder already in that state is returned to the top level. [#28748](https://github.com/open-webui/open-webui/pull/28748)
|
||||
- 🧨 **Searching a knowledge base with a costly pattern.** A search pattern written so that it expands enormously before it even runs is now refused, where it could tie up the server; ordinary patterns are unaffected. [Commit](https://github.com/open-webui/open-webui/commit/d5b66533e7829654f6fb343abbaaeab988dbfde8), [#28284](https://github.com/open-webui/open-webui/pull/28284)
|
||||
- 💧 **Attaching a very large file from a link.** A file fetched from a link is now written to disk as it arrives and stops at the configured size limit, where the whole thing was held in memory first with no limit applied, so a large enough file could exhaust the server; a download that fails partway no longer leaves the partial file behind. [#28945](https://github.com/open-webui/open-webui/pull/28945)
|
||||
- ⛓ **Deleting one knowledge base removing a shared connection.** Deleting an external knowledge base now leaves its connection in place while other knowledge bases still use it, and only an administrator removing the last one clears it, where any user deleting theirs took the connection away from everyone. [#28113](https://github.com/open-webui/open-webui/pull/28113)
|
||||
- 📡 **Intermittent connection failures.** Requests to model providers and to services on the same network no longer fail intermittently with name lookup errors, often surfacing as a misleading model not found message, because addresses are resolved through the system again by default, with the faster resolver still available through "AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER". [#28242](https://github.com/open-webui/open-webui/pull/28242), [#28013](https://github.com/open-webui/open-webui/issues/28013), [#28215](https://github.com/open-webui/open-webui/issues/28215)
|
||||
- 🗯️ **Losing the conversation with memory on.** With the memory tool enabled, the model can see the earlier messages in your conversation again, instead of answering the second message as though the first had never been sent. [#28400](https://github.com/open-webui/open-webui/issues/28400)
|
||||
- 👻 **Vanishing responses.** Replies from Responses-API providers that report an empty output at the end of a stream no longer disappear the moment generation finishes, leaving an empty message in their place. [#27800](https://github.com/open-webui/open-webui/pull/27800), [#27789](https://github.com/open-webui/open-webui/discussions/27789)
|
||||
- 📥 **Queued messages disappearing.** Messages waiting to be sent are put back in the queue if sending them fails, rather than vanishing without being sent. [Commit](https://github.com/open-webui/open-webui/commit/f79b443c226098564386d0a31708e71ec0146158)
|
||||
- 🧵 **Replies cut short mid-stream.** A reply no longer breaks off part way through when a provider sends the pieces of its response in an unexpected order, which had left the answer truncated and skipped the filters that run once a message finishes. [#28312](https://github.com/open-webui/open-webui/pull/28312)
|
||||
- 🧷 **Replies not carried into the next turn.** With providers that skip parts of the streaming sequence, the finished reply is now taken from the completed message, so it stays available as context for your next question and the citations that arrived with it are no longer dropped. [#28310](https://github.com/open-webui/open-webui/pull/28310)
|
||||
- 🌊 **Replies arriving in oversized pieces.** Very large streamed pieces no longer break the response on default settings, where the reader that splits them safely only ran when a chunk size limit was configured. [Commit](https://github.com/open-webui/open-webui/commit/a33fa05adc6def8f3d098539a4786dc9c7bf61d2)
|
||||
- 🩹 **Signing in after a long-delayed upgrade.** Accounts on instances that were upgraded from a version older than 0.6.41 to 0.9.6 or newer can sign in again, where an upgrade step had written their single sign-on identity in a form the application could not read afterwards, and a repair step corrects the affected accounts on startup. [#28107](https://github.com/open-webui/open-webui/pull/28107), [#28101](https://github.com/open-webui/open-webui/issues/28101), [Commit](https://github.com/open-webui/open-webui/commit/bd8378f643dd8a56366ad9d1843b7d40f67cf1f9)
|
||||
- 🔑 **Signing in with some identity providers.** Logging in through a provider that adds its own vendor-specific information to the header of the sign-in token now completes, rather than failing at the final step with a message claiming the email or password was wrong. [#28065](https://github.com/open-webui/open-webui/pull/28065), [#28062](https://github.com/open-webui/open-webui/issues/28062)
|
||||
- 🔌 **Role changes taking effect at once.** Changing someone's role now ends their live sessions no matter how the change was made, whether by a directory sync, an identity provider, a trusted header, or deleting the account, so permissions from their old role cannot linger, and their browser reconnects on its own. [Commit](https://github.com/open-webui/open-webui/commit/ce3c175e260709f359d7e6cbb3132f0572098b95)
|
||||
- 🛑 **Memory permission being respected.** Taking away someone's memory permission now also stops their stored memories being added to the context of their conversations, which one path had continued doing regardless. [#27668](https://github.com/open-webui/open-webui/pull/27668)
|
||||
- 🔍 **Listing a single connection's models.** Asking for the models or version of one particular connection is now restricted to administrators, and a request naming a specific backend is checked against the models that backend actually serves even where the access control bypass is turned on. [Commit](https://github.com/open-webui/open-webui/commit/16f118d77ad9d68c64116f94551ffb14bf8b2abd)
|
||||
- ⚖️ **Sharing defaults matching what was configured.** On instances upgraded from older versions, public sharing of tools and notes no longer shows as switched on in the admin panel, and saving any unrelated permission no longer grants everyone a capability that was never enabled. [#27716](https://github.com/open-webui/open-webui/pull/27716), [#27715](https://github.com/open-webui/open-webui/issues/27715)
|
||||
- 🗂️ **Folder permissions when starting a chat.** Starting a conversation filed into a folder now checks that you are allowed to write to that folder, a check the message sending path had been skipping, and every place a chat can be filed now treats ownership, shared access, and unknown folders the same way. [#28366](https://github.com/open-webui/open-webui/pull/28366)
|
||||
- 🪧 **Clearer attachment failures.** A link that cannot be read now says so and names the link, and a YouTube video whose transcript is refused explains why and points at the proxy setting that exists for it, instead of both being reported as a knowledge base error. [#28362](https://github.com/open-webui/open-webui/pull/28362), [#28361](https://github.com/open-webui/open-webui/issues/28361)
|
||||
- 🔎 **Chat search finding recent messages.** Searching your chats now looks inside the messages of current conversations on default installations, where it had only been reading an older storage format and missing their content entirely. [Commit](https://github.com/open-webui/open-webui/commit/0800c21c64c64810f24c2ec88cca5e36daebb10e)
|
||||
- 🧭 **Your place in a compacted chat.** Opening a conversation whose history has been compacted now takes you to its most recent message instead of leaving you parked on the summary, and updates to an existing message no longer move your place in the conversation. [Commit](https://github.com/open-webui/open-webui/commit/5caa91a49304148696641d1cd39e43184ba8d748)
|
||||
- 🖥️ **Chats with a personal terminal.** Sending a message with a terminal you added yourself under Settings selected no longer fails with a terminal unavailable error, which had blocked those chats since 0.11.0. [#27621](https://github.com/open-webui/open-webui/issues/27621), [Commit](https://github.com/open-webui/open-webui/commit/5b333d75c6adea4d8bde96439974d6e9c83d8198), [Commit](https://github.com/open-webui/open-webui/commit/3becec6ccfc7dc457270d4e9f0d8c957e8160ddc)
|
||||
- 📆 **Default date for new events.** Creating a calendar event now starts on today's date rather than tomorrow's when you open the form in the evening, or yesterday's when you open it early in the morning. [#27779](https://github.com/open-webui/open-webui/pull/27779), [#27778](https://github.com/open-webui/open-webui/issues/27778)
|
||||
- 🗓️ **Recurring event times.** Repeating calendar events now show at the time you set them for instead of being worked out in the server's time zone and shifted by the gap between the two. [#27774](https://github.com/open-webui/open-webui/issues/27774), [Commit](https://github.com/open-webui/open-webui/commit/d721b0d19621d01e35505105a778ce5681b2ffba)
|
||||
- 🧩 **Chats during model list refreshes.** On direct connections, background work such as title and tag generation no longer fails or runs against a mix of old and new model entries while the model list is being refreshed. [#27821](https://github.com/open-webui/open-webui/pull/27821)
|
||||
- 🎛️ **Chat Controls staying put.** Hovering a chat in the sidebar whose preview contains an artifact no longer forces the Chat Controls pane open and fills it with that artifact, over the chat you currently have open. [#27773](https://github.com/open-webui/open-webui/pull/27773), [#27772](https://github.com/open-webui/open-webui/issues/27772)
|
||||
- 📨 **Reliable streaming with unusual characters.** Responses containing any of three rare invisible line break characters no longer arrive split or broken when the "ENABLE_ORJSON" option is turned on. [#27819](https://github.com/open-webui/open-webui/pull/27819)
|
||||
- 🧮 **JSON options honoured again.** Options passed to the shared JSON helper are no longer silently ignored when the "ENABLE_ORJSON" option is turned on, falling back to the standard encoder that supports them. [Commit](https://github.com/open-webui/open-webui/commit/78ed5a0235c4de67828ba4b0d6147035067cec2c)
|
||||
- 🚫 **Duplicate models in lists.** Adding a model that is already on a connection's allowed list is now rejected instead of quietly adding it a second time, the arena picker no longer offers models you have already chosen, and existing duplicates are cleaned up the next time the list is saved. [#28251](https://github.com/open-webui/open-webui/pull/28251), [#28249](https://github.com/open-webui/open-webui/issues/28249)
|
||||
- 📁 **Dragging chats into shared folders.** A shared folder you can write to now highlights and accepts a dropped chat, while one you only have read access to no longer offers itself as a drop target for an action that could only fail. [Commit](https://github.com/open-webui/open-webui/commit/e4dd6c4bf14c7ea54c1effa6c40ad9e95185c413), [#28261](https://github.com/open-webui/open-webui/issues/28261)
|
||||
- ⏰ **Listing automations through chat.** Asking a model to list your automations without naming a folder now returns every automation you have, instead of only the ones that sit outside a folder. [Commit](https://github.com/open-webui/open-webui/commit/f8ac75d188b7e07906aa1d5e648e3fc25a78ef2b)
|
||||
- 🧠 **Faster follow-ups with memory enabled.** The memories handed to the model now appear in a stable order from one message to the next, so servers that reuse their work between turns no longer reprocess the whole conversation each time you reply. [#28292](https://github.com/open-webui/open-webui/issues/28292), [Commit](https://github.com/open-webui/open-webui/commit/ff74bfa6a117c6f03097034c74c2f9bdfb824203), [Commit](https://github.com/open-webui/open-webui/commit/d22bb6703f244e6769ac9b0f2e41d784ad6cf535)
|
||||
- 🎯 **Custom model parameters combining.** Setting a custom parameter on a model no longer silently discards every custom parameter defined in the global defaults, and a value sent directly in an API request is no longer overwritten by the model's saved settings. [Commit](https://github.com/open-webui/open-webui/commit/11739a2de8eddf7ef26378356aff8a1bcab4a349), [#28241](https://github.com/open-webui/open-webui/issues/28241)
|
||||
- 👥 **Sharing with people already added.** The access picker no longer offers people and groups that already have access, and the Users heading no longer appears above an empty list. [Commit](https://github.com/open-webui/open-webui/commit/385d08bea5899bcb160391db466662b0a0a6f801), [#28253](https://github.com/open-webui/open-webui/issues/28253)
|
||||
- 🔧 **Full tool parameter descriptions.** A tool whose parameter description runs over several lines now passes the whole description to the model instead of only its first line. [Commit](https://github.com/open-webui/open-webui/commit/b606e13da3753027ead0e92e35e1399b60c96c8e)
|
||||
- 📝 **Notes saved in an unexpected shape.** A note whose content was stored as structured data rather than text no longer breaks the notes page for everything else, and opens with that content shown as a formatted code block. [Commit](https://github.com/open-webui/open-webui/commit/8d1c205d8e7335ef7c292afb125a7270976f327b), [#28222](https://github.com/open-webui/open-webui/issues/28222)
|
||||
- 💬 **Direct messages after an account is deleted.** A direct message conversation no longer counts a deleted account among its members, and opening a direct message with someone finds the existing conversation instead of starting a second one alongside it. [Commit](https://github.com/open-webui/open-webui/commit/a41faa3c226b20e7db7508c49c7d1d015ac9e973), [#28257](https://github.com/open-webui/open-webui/issues/28257)
|
||||
- 🫥 **Deactivating a model.** Turning a model off no longer removes the wrong entry from the model list, or fails the list outright and leaves the model picker empty for everyone until the model is turned back on. [Commit](https://github.com/open-webui/open-webui/commit/5cecb7dbfad3994228ee53d19232650400462332), [#28202](https://github.com/open-webui/open-webui/issues/28202)
|
||||
- 💭 **Readable errors on chat actions.** When moving, renaming, or otherwise changing a chat fails, the message explaining why now appears in place of an unhelpful object placeholder. [#28260](https://github.com/open-webui/open-webui/pull/28260), [#28259](https://github.com/open-webui/open-webui/issues/28259)
|
||||
- 🪪 **Authorship in shared chats.** A chat shared with you now shows the name and picture of whoever wrote it, in the message list and in the overview panel, rather than crediting the messages to you. [#28274](https://github.com/open-webui/open-webui/pull/28274), [#28273](https://github.com/open-webui/open-webui/issues/28273)
|
||||
- 🖇️ **Adding terminals over plain connections.** Saving a terminal connection now works when the interface is served without HTTPS, where the dialog would sit there doing nothing because the browser withholds the tool used to generate its identifier. [Commit](https://github.com/open-webui/open-webui/commit/2a45fa04cb1d07b258e0b880ab0c02577f0dc035), [#28148](https://github.com/open-webui/open-webui/issues/28148)
|
||||
- 🗃️ **openGauss vector storage.** Deployments using openGauss for vector storage no longer fail the moment they touch it. [#27838](https://github.com/open-webui/open-webui/pull/27838)
|
||||
- 🎚️ **ColBERT reranker startup.** Loading a ColBERT reranker now names the model in the log rather than printing a logging error and a traceback in its place. [#27838](https://github.com/open-webui/open-webui/pull/27838)
|
||||
- 🖼️ **Images that no longer exist.** A message whose image file has been deleted now shows a small unavailable placeholder that cannot be opened, rather than a broken image that spilled the whole reply text into the picture frame and still opened full screen. [#27730](https://github.com/open-webui/open-webui/pull/27730), [#27728](https://github.com/open-webui/open-webui/issues/27728), [Commit](https://github.com/open-webui/open-webui/commit/f8c5fda283e6206d92e81ae3b83ffe5d7a144b03)
|
||||
- 🛂 **Connecting external accounts.** Authorizing a tool's external account now completes only for the person who started it, rather than for whoever happens to return with the authorization, and signing out clears the session it relies on. [Commit](https://github.com/open-webui/open-webui/commit/c2107e5bb3689a69c170ca526925f4ed84bd00f5)
|
||||
- 🪟 **Starting up on Windows.** The Windows start script now creates the secret key it needs on a fresh installation, instead of printing a run of file not found messages and then refusing to start, and it copes with an installation path that contains spaces. [#28061](https://github.com/open-webui/open-webui/pull/28061), [#28060](https://github.com/open-webui/open-webui/issues/28060)
|
||||
- 🕸️ **Overlapping branches in the overview.** Branch nodes in a chat's overview keep a clear gap between them when the interface is scaled up, rather than sitting on top of one another. [#27995](https://github.com/open-webui/open-webui/pull/27995), [#27994](https://github.com/open-webui/open-webui/issues/27994)
|
||||
- 🗑️ **Delete offered only when allowed.** The chat deletion controls in the sidebar, the chat menu, search, archived chats, and data controls no longer appear for people whose permissions do not allow deleting, where using them produced an access denied error. [#27714](https://github.com/open-webui/open-webui/pull/27714), [#27713](https://github.com/open-webui/open-webui/issues/27713)
|
||||
- 🍴 **Fork offered only when allowed.** The fork action and the fork command no longer appear for people whose chat import permission is turned off, where using them produced an access denied error. [#27711](https://github.com/open-webui/open-webui/pull/27711), [#27692](https://github.com/open-webui/open-webui/issues/27692)
|
||||
- ↕️ **Expand button in the message box.** The button that enlarges the message box no longer sits on top of a tagged model's dismiss button or the first attached file, and stays reachable in long prompts. [#27676](https://github.com/open-webui/open-webui/pull/27676), [#26736](https://github.com/open-webui/open-webui/issues/26736)
|
||||
- 🔆 **Regenerate in high contrast mode.** With high contrast mode on, the regenerate button now stays visible on earlier replies instead of appearing only when you hover over them. [#27644](https://github.com/open-webui/open-webui/pull/27644), [#27638](https://github.com/open-webui/open-webui/issues/27638)
|
||||
- ✂️ **Clipped icons and avatars.** The terminal icon beside the message box and the profile picture in account settings are no longer shaved flat along their left edge. [#27691](https://github.com/open-webui/open-webui/pull/27691), [#27690](https://github.com/open-webui/open-webui/issues/27690)
|
||||
- 🪄 **Merged responses after a reload.** Merging the answers from several models now works on a conversation you have reopened, instead of the merging model reporting that the other responses were empty. [#27673](https://github.com/open-webui/open-webui/pull/27673), [#26962](https://github.com/open-webui/open-webui/issues/26962)
|
||||
- 🔢 **Token counts for background chats.** Conversations started by automations, timers, sub-agents, and channels now report their token usage like any other chat, rather than arriving without it even when the model is set up to provide it. [#27661](https://github.com/open-webui/open-webui/pull/27661), [#27653](https://github.com/open-webui/open-webui/issues/27653)
|
||||
- 📐 **Settings on tall screens.** The settings window now grows with the height of your display instead of stopping short and making you scroll inside it while space sits unused above and below. [#27615](https://github.com/open-webui/open-webui/pull/27615), [#27614](https://github.com/open-webui/open-webui/issues/27614)
|
||||
- 🖱️ **Sections opening by accident.** Folders, collapsible sections, and tool call blocks now open and close only when you click them, rather than also reacting when you release the mouse over them after dragging or selecting text. [Commit](https://github.com/open-webui/open-webui/commit/bd250a0e2431f8c2c7e2f4a34c5327f21fc77d4b)
|
||||
- 🔁 **Rebuilding knowledge base vectors.** Rebuilding the vectors for a knowledge base now also rebuilds them for each file it contains, so attaching a single file afterwards finds its content instead of quietly returning nothing and letting the model answer from thin air. [#28106](https://github.com/open-webui/open-webui/issues/28106), [Commit](https://github.com/open-webui/open-webui/commit/2a6e671f548970c8223692024e630b9936e9fa7c), [Commit](https://github.com/open-webui/open-webui/commit/89922cc9d585e10b026693681b44afdc4b874588)
|
||||
- 📌 **Attaching a chat shared with you.** Attaching a conversation that was shared with you, directly or through a shared folder, now brings its content along instead of quietly attaching nothing. [Commit](https://github.com/open-webui/open-webui/commit/5cd9a395344e882b4384bec0899f503ad540aecc)
|
||||
- 🧲 **The page staying still when typing.** Returning focus to the message box no longer scrolls the conversation, so switching chats, running a command, or picking something from a menu leaves your place on screen alone. [Commit](https://github.com/open-webui/open-webui/commit/9122c24ea2d506aad50384ca8aba1616d1bed626)
|
||||
- 📷 **Round profile pictures on narrow screens.** Profile pictures in the admin user list and other lists no longer squash into ovals of differing widths when the window is narrow. [#28000](https://github.com/open-webui/open-webui/pull/28000), [#27999](https://github.com/open-webui/open-webui/issues/27999)
|
||||
- 🎙️ **Voice mode in the notes editor.** The voice mode button is no longer offered in the chat embedded in a note, where it does not apply. [Commit](https://github.com/open-webui/open-webui/commit/9122c24ea2d506aad50384ca8aba1616d1bed626), [Commit](https://github.com/open-webui/open-webui/commit/e963d36e393aead56b941e99d4167fb0dc9d2d7a)
|
||||
- ⏳ **Analytics stuck loading.** Choosing a custom date range in analytics without picking dates yet no longer leaves the tab spinning forever, including after leaving and coming back to it. [#28125](https://github.com/open-webui/open-webui/issues/28125), [Commit](https://github.com/open-webui/open-webui/commit/629cdcb5303958c5b99ee437030ab80cc418bc2e)
|
||||
- 🧑🤝🧑 **Owner avatars on shared chats.** The picture beside a chat someone shared with you now loads, and falls back to the default image if it cannot, rather than leaving a blank gap when the interface and the server are on different addresses. [#28272](https://github.com/open-webui/open-webui/pull/28272), [#28271](https://github.com/open-webui/open-webui/issues/28271)
|
||||
- 🎨 **Image generation and web search staying switched off.** Turning either off now takes effect at once on every path: sessions opened beforehand can no longer produce images or run searches, an image request no longer reaches the provider on a model using the older tool-calling method, the entry disappears from the integrations menu right away, and an active marker beside the message box no longer lingers after its feature is withdrawn. [#27759](https://github.com/open-webui/open-webui/pull/27759), [#27758](https://github.com/open-webui/open-webui/issues/27758), [#26842](https://github.com/open-webui/open-webui/issues/26842), [#27669](https://github.com/open-webui/open-webui/pull/27669)
|
||||
- 📰 **Attached pages reaching the model.** The text pulled from an attached web page or YouTube video now actually reaches the model, rather than arriving empty so the reply had nothing to work from, and opening the source to check no longer fails. [#28378](https://github.com/open-webui/open-webui/issues/28378), [Commit](https://github.com/open-webui/open-webui/commit/9c21d4ed3ba9ba8e53def7e1a1366b74834cb42d)
|
||||
- 🌐 **Tavily page fetching.** Reading a web page with Tavily selected as the loader works again, having failed on every attempt since 0.10.0. [#27636](https://github.com/open-webui/open-webui/pull/27636), [#27602](https://github.com/open-webui/open-webui/issues/27602)
|
||||
- 🗒️ **Reply box in threads.** The reply box in a channel thread now stays at the bottom of the panel while you scroll back through the replies, instead of scrolling out of sight with them. [#27768](https://github.com/open-webui/open-webui/pull/27768), [#27767](https://github.com/open-webui/open-webui/issues/27767)
|
||||
- 🎹 **Model picker shortcut.** The keyboard shortcut for opening the model picker works again, and a link to a chat naming a model you do not have still opens the picker with that name filled in. [Commit](https://github.com/open-webui/open-webui/commit/e1acd7e7ca5085e03babbea16b4770e51c38886e)
|
||||
- ⌨️ **Reaching the download options by keyboard.** In the model picker, arrowing past the last result now moves through the options to fetch that model from each server that can supply it, so they can be chosen with the keyboard instead of only by clicking. [Commit](https://github.com/open-webui/open-webui/commit/4e03d89414a7be1203579f45225ca64f1221205f), [Commit](https://github.com/open-webui/open-webui/commit/25802c048e6123fa602182949ad2a9f349e1e863)
|
||||
- 📂 **Opening a folder in the sidebar.** Selecting a folder now refreshes just that folder's chats rather than rebuilding the whole folder tree, and a folder that is empty or still loading says so instead of showing nothing. [Commit](https://github.com/open-webui/open-webui/commit/2e939874904e1ae54d02b8bc6d745931a8e1f673), [Commit](https://github.com/open-webui/open-webui/commit/86b7bf1f7e11109816b0d16ada708c637b50c520)
|
||||
- ⏲️ **Changing an automation through chat.** Asking a model to change one thing about an automation no longer moves it out of its folder or drops its model when the model fills those fields in blank instead of omitting them. [Commit](https://github.com/open-webui/open-webui/commit/90bb94abf9a8c6fbb87c72952295ae39d2ad98e5)
|
||||
- 🔂 **Automations that run a set number of times.** An automation asked to run a limited number of times is now rejected unless it says when to start counting from, rather than being accepted and then running indefinitely. [#27781](https://github.com/open-webui/open-webui/pull/27781), [#27780](https://github.com/open-webui/open-webui/issues/27780)
|
||||
- 📅 **Editing a calendar event through chat.** Asking a model to change one thing about an event, such as its title, no longer fails or wipes the details you did not mention. [#27777](https://github.com/open-webui/open-webui/pull/27777), [#27776](https://github.com/open-webui/open-webui/issues/27776)
|
||||
- 🧹 **Session cleanup on multi-instance setups.** The instance doing the periodic session cleanup now keeps its claim on that job alive between passes, so an idle deployment stops logging a renewal warning every two minutes and the claim no longer lapses for half of every cycle. [Commit](https://github.com/open-webui/open-webui/commit/939bcdb79e3dcad2278f1e7f4f517a3f2f36f3ec), [#27762](https://github.com/open-webui/open-webui/issues/27762)
|
||||
- 🏠 **Starting folder in the file browser.** Reopening the file browser now keeps the folder you were in, instead of the breadcrumb losing its starting point and jumping you elsewhere. [Commit](https://github.com/open-webui/open-webui/commit/52c5e3b20d3cd8c3a15db50e2cc75bbd6e82068e)
|
||||
- 🩺 **Repairing default model settings.** Instances whose stored default and pinned model settings had been written in the wrong shape are corrected on startup, so those defaults take effect again. [Commit](https://github.com/open-webui/open-webui/commit/5c05608e3ac0aefce8c1eed18c3e205c61f6b6fe)
|
||||
- 🧽 **Cleaner conversation history for the model.** Internal bookkeeping attached to your messages, such as attachment records and token counts, is no longer sent to the model along with the conversation. [Commit](https://github.com/open-webui/open-webui/commit/a32a17965ca36f730ebf5ff53a2a8e17082b61a6)
|
||||
- 🙈 **Needless request from the model picker.** Opening the model picker as a non-administrator no longer fires a request to an administrator-only settings endpoint that was always refused. [Commit](https://github.com/open-webui/open-webui/commit/b4738d1a2e6af1ce20adb322fc6d2aa2c9407901)
|
||||
- ⏹️ **Stopping a reply that is waiting.** The stop button now ends a reply that is sitting waiting for you, such as one paused on a tool approval, rather than leaving the conversation stuck part way through. [Commit](https://github.com/open-webui/open-webui/commit/f7767d6be774720e16265c6d016bf6a24a1a8759)
|
||||
- 🏷️ **Folder names with unusual characters.** Naming a folder is no longer refused because another folder's name happens to be similar, and a name ending in a backslash no longer fails outright on PostgreSQL, because names are now compared exactly rather than treated as a search pattern. [#28695](https://github.com/open-webui/open-webui/pull/28695), [#28694](https://github.com/open-webui/open-webui/issues/28694)
|
||||
- 🤔 **Reasoning carried back to Ollama.** A model's earlier thinking is now passed back to Ollama in its own native field rather than pasted into the message as tagged text, so reasoning models keep their train of thought across turns. [Commit](https://github.com/open-webui/open-webui/commit/3258330729942b533dc5fe141e876cee7e5eb40d)
|
||||
- 📎 **Default pinned models taking effect.** Changing the default pinned models now reaches people who have never chosen their own, where simply having opened the interface once was enough to freeze the list they first saw, and reordering a pin no longer moves the wrong one or reopens the sidebar section afterwards. [#28069](https://github.com/open-webui/open-webui/pull/28069), [#28067](https://github.com/open-webui/open-webui/discussions/28067)
|
||||
- ✏️ **Editing other people's channel messages.** Asking a model to work on a message in a channel now only applies to your own messages, where write access to the channel had been enough to reach anyone's. [#28631](https://github.com/open-webui/open-webui/pull/28631)
|
||||
- 🍪 **Signed-in tool servers.** A tool server that relies on your session now receives the credentials belonging to its own connection, rather than whichever were most recently prepared. [#28630](https://github.com/open-webui/open-webui/pull/28630)
|
||||
- 🏗️ **Editing a folder from its page.** Renaming a folder, changing its icon, creating a subfolder, or deleting it from the folder's own page now updates the sidebar straight away, instead of leaving the old name and icon there, and the new subfolder missing, until a reload. [Commit](https://github.com/open-webui/open-webui/commit/a40f6f2860b49b4c7f11e369f11bd46e9451eabf), [Commit](https://github.com/open-webui/open-webui/commit/736e38338ede1fca5af0829e35585a72f8517d32), [#28692](https://github.com/open-webui/open-webui/pull/28692), [#28690](https://github.com/open-webui/open-webui/issues/28690)
|
||||
- 📣 **Long channel names in the sidebar.** A channel with a long name no longer squeezes its own menu button out of the row. [Commit](https://github.com/open-webui/open-webui/commit/b5da50f3df51786972010c75c7736d5f95362e58), [#28671](https://github.com/open-webui/open-webui/pull/28671), [#28670](https://github.com/open-webui/open-webui/issues/28670)
|
||||
- 📬 **Mark as unread in chat search.** Marking a chat unread from the search dialog now works and updates the sidebar, where the menu entry looked normal but did nothing at all. [#28136](https://github.com/open-webui/open-webui/pull/28136), [#28135](https://github.com/open-webui/open-webui/issues/28135)
|
||||
- ⬆️ **Scroll to top on the first click.** In a long chat where older messages had not been loaded yet, one click of scroll to top now reaches the first message instead of stopping short and needing a second. [#28659](https://github.com/open-webui/open-webui/pull/28659), [#28658](https://github.com/open-webui/open-webui/issues/28658)
|
||||
- 🔘 **Double bullets in the release notes.** Each entry in the what's new dialog shows a single bullet again, rather than two sitting at different heights. [#28676](https://github.com/open-webui/open-webui/pull/28676), [#28675](https://github.com/open-webui/open-webui/issues/28675)
|
||||
- 📚 **Knowledge search and shared files in chat.** A model searching your knowledge bases or reading a file shared with you through a group now works, where it had failed since 0.11.0 and quietly answered as though the knowledge were empty, affecting instances that forward user details to their embedding service and, for shared files, every instance regardless of settings. [#27642](https://github.com/open-webui/open-webui/pull/27642), [#27641](https://github.com/open-webui/open-webui/issues/27641)
|
||||
- 🔖 **Skill identifiers that cannot be reached.** Creating a skill whose identifier contains a character that is not allowed in a web address is now refused outright, rather than accepted and then permanently impossible to open, edit, turn off, delete, or recreate. [#27660](https://github.com/open-webui/open-webui/pull/27660), [#27655](https://github.com/open-webui/open-webui/issues/27655)
|
||||
- 🔤 **Model names on connections with a prefix.** A connection that adds a prefix to its model names now strips it before sending a request through the responses endpoint, where the prefixed name was passed on and rejected as unknown. [#28575](https://github.com/open-webui/open-webui/pull/28575), [#28574](https://github.com/open-webui/open-webui/issues/28574)
|
||||
- 🔓 **Turning on open sharing.** The open sharing permission can now be switched on in the default user permissions, where saving appeared to work but the setting was discarded and came back off. [#27609](https://github.com/open-webui/open-webui/pull/27609), [#27607](https://github.com/open-webui/open-webui/issues/27607)
|
||||
- 🖌️ **White boxes behind model icons.** Model icons with a transparent background no longer sit on a white square in the admin models list, matching how they already appeared everywhere else. [#27612](https://github.com/open-webui/open-webui/pull/27612), [#27611](https://github.com/open-webui/open-webui/issues/27611)
|
||||
- 🪞 **Matching the right account at sign-in.** Looking up an account by its identity provider details now matches the exact value, where the stored details were searched as loose text and a value contained within another's could be matched instead. [#28624](https://github.com/open-webui/open-webui/pull/28624)
|
||||
- 🔄 **Syncing a model catalogue more than once.** Syncing models now updates the ones that already exist, where any repeat of a previous sync silently did nothing at all while still reporting success. [#28036](https://github.com/open-webui/open-webui/pull/28036), [#28033](https://github.com/open-webui/open-webui/issues/28033)
|
||||
- 🕰️ **Saving a calendar event without a date.** Creating or editing an event with the date cleared now asks for one, where it was sent anyway, refused by the server, and reported as an unreadable error. [Commit](https://github.com/open-webui/open-webui/commit/f100edb70874808c93fab84eae1810595f9e9dc3), [#28133](https://github.com/open-webui/open-webui/issues/28133)
|
||||
- 🔗 **Deleting a message in a looping chat.** Deleting a message no longer hangs when the conversation contains a cycle in its reply structure. [#28035](https://github.com/open-webui/open-webui/pull/28035)
|
||||
- 🫀 **Scheduled work stopping without warning.** The routine that runs automations and calendar alerts can no longer be discarded while the application is running, which had silently stopped them firing with nothing reported, and it now stops cleanly on shutdown. [#28053](https://github.com/open-webui/open-webui/pull/28053), [#28052](https://github.com/open-webui/open-webui/issues/28052)
|
||||
- 🔋 **Session and usage records left uncleared.** The routines that clear out stale sessions and finished model usage can no longer be discarded while the application is running, so those records stop accumulating unnoticed, and both now stop cleanly on shutdown. [#28053](https://github.com/open-webui/open-webui/pull/28053), [#28052](https://github.com/open-webui/open-webui/issues/28052)
|
||||
- ⚗️ **Reasoning carried between turns.** A model's earlier thinking is now recognised from providers that report it in their own nested field, and reasoning that cannot be sent back without a signature is left out rather than being passed on and rejected. [Commit](https://github.com/open-webui/open-webui/commit/b6dc70c93b0d36e2659438e7a55aa8b21fee27f8)
|
||||
- 🛎️ **Losing all your settings.** Your interface settings are no longer wiped by a session that failed to load them, which could happen with no action on your part and cleared everything from your theme to your model parameters; saving now changes only the settings you actually changed, and a session that cannot load them tells you instead of carrying on as though you had none. [#27766](https://github.com/open-webui/open-webui/issues/27766), [Commit](https://github.com/open-webui/open-webui/commit/ad8c79f68657bd3bcf5db6be650e498bb904b36b)
|
||||
- 🖲️ **Losing the collapsed sidebar.** With the sidebar collapsed, opening a chat no longer pushes the narrow sidebar strip off the edge of the screen, which left no way to reopen the sidebar short of shrinking the window to phone size. [#28501](https://github.com/open-webui/open-webui/pull/28501), [#28500](https://github.com/open-webui/open-webui/issues/28500)
|
||||
- 🧯 **Timers that fail without saying so.** A timer whose reply cannot be generated, such as one set against a model that has since been removed, is now recorded as failed with the reason, instead of being marked as completed while the reply never arrives. [#27785](https://github.com/open-webui/open-webui/pull/27785), [#27783](https://github.com/open-webui/open-webui/issues/27783)
|
||||
- 🖊️ **Message buttons in channels.** The buttons that appear when you hover a channel message now sit above the message rather than over its content, so they can be clicked on a message that starts with a code block or a table, and so the code and table controls stay clickable too. [#27737](https://github.com/open-webui/open-webui/pull/27737), [#27736](https://github.com/open-webui/open-webui/issues/27736)
|
||||
- 🔡 **Searching for non-English tags and text.** Searching workspace models by tag, or prompts and automations by their contents, now finds entries containing characters outside the English alphabet, where roughly half were missed depending on which settings were in force when each one was saved. [#28399](https://github.com/open-webui/open-webui/pull/28399)
|
||||
- 🔭 **Searching the calendar without an end date.** Asking a model to search your calendar without naming an end date now works on PostgreSQL, where the open-ended range was too large for the database to accept and the search failed outright. [Commit](https://github.com/open-webui/open-webui/commit/9550731cc17759f6862595b8cd849ae48695b5c1), [#27717](https://github.com/open-webui/open-webui/issues/27717)
|
||||
- 🫧 **Attachments replaced by a loading dot.** Pinning a channel message, or otherwise updating one, no longer replaces its attachment with a loading indicator that never resolves until you reload or leave the channel. [Commit](https://github.com/open-webui/open-webui/commit/76d01602950a9e40823b53278557708d5bcd1036), [#27734](https://github.com/open-webui/open-webui/pull/27734), [#27731](https://github.com/open-webui/open-webui/issues/27731)
|
||||
- 🛠️ **Rebuilding empty server lists on every request.** An instance with no tool servers or no terminal servers configured no longer rebuilds that empty list on every request that needs it. [Commit](https://github.com/open-webui/open-webui/commit/f1a64ccfc2eb2a58086c55fe413a455bb488c35a), [#28568](https://github.com/open-webui/open-webui/issues/28568)
|
||||
- 🪫 **Errors logged for a cache that was simply empty.** A shared cache that has not been filled yet no longer logs an error suggesting its stored value is broken. [Commit](https://github.com/open-webui/open-webui/commit/f1a64ccfc2eb2a58086c55fe413a455bb488c35a), [#28568](https://github.com/open-webui/open-webui/issues/28568)
|
||||
- ⚓ **Starting up as a non-root user.** Deployments that run the container as a non-root user, such as Kubernetes setups using runAsNonRoot, start again, where a bundled speech model file that only root could read had stopped them since 0.11.0, and a bundled text corpus is now stored somewhere a non-root user can reach. [#27651](https://github.com/open-webui/open-webui/issues/27651), [Commit](https://github.com/open-webui/open-webui/commit/0480ca9653f0d566eaedadf0af0d785a9938480b), [#28866](https://github.com/open-webui/open-webui/pull/28866)
|
||||
- 📋 **Finding notes shared read only.** A note shared publicly for reading now appears in the read only view of your notes, where it was readable by anyone with the link but listed nowhere at all. [#27637](https://github.com/open-webui/open-webui/pull/27637), [#27487](https://github.com/open-webui/open-webui/issues/27487)
|
||||
- 🫂 **Signing in from another application.** Signing in through an application that exchanges a token from your identity provider now applies your role and group memberships the same way signing in through the browser does, and an account whose provider sends no role keeps the one it has rather than being reset to the default. [Commit](https://github.com/open-webui/open-webui/commit/d799e81edbdc971c6deb096b6474cd95b93504bf), [Commit](https://github.com/open-webui/open-webui/commit/e9684458125c3202ebc3378aef32f0b6015171d8)
|
||||
- 🗄️ **Empty models section in the sidebar.** The models section no longer appears with nothing in it when every pinned model has since been removed, renamed, or hidden. [#27634](https://github.com/open-webui/open-webui/pull/27634), [#27633](https://github.com/open-webui/open-webui/issues/27633)
|
||||
- 🚧 **The files pane reopening by itself.** With a terminal selected, closing the files pane now keeps it closed, where saving any setting reopened it, including something as incidental as picking an emoji for a folder. [#28693](https://github.com/open-webui/open-webui/pull/28693), [#28691](https://github.com/open-webui/open-webui/issues/28691), [Commit](https://github.com/open-webui/open-webui/commit/c1c81f8127466a4cd59a16d8658155b1b8faa2d0)
|
||||
- 🚰 **Watching file processing tying up the database.** Waiting for a file or a knowledge base to finish processing no longer holds a database connection open for as long as the page is watching, which on busy instances could use up every available connection and leave the rest of the application unable to reach the database. [#28183](https://github.com/open-webui/open-webui/pull/28183)
|
||||
- 🗜️ **Embedding settings for other providers.** Saving your embedding settings now writes only the provider you have selected, where it also overwrote the address and key stored for the other two, losing them if their fields were not filled in. [Commit](https://github.com/open-webui/open-webui/commit/87d9b7e84e71b097eadf1df4f9852359104f17ed)
|
||||
- ↔️ **Connectors in the side-by-side overview.** With the conversation overview laid out left to right, the lines between messages now join at the sides rather than the top and bottom, so they no longer cut across the boxes. [Commit](https://github.com/open-webui/open-webui/commit/6e468c5b9539d3b6057bab63d81e8d2974acff7b)
|
||||
- 💫 **Thinking indicator with the fade turned off.** Turning off the fade effect for streaming text no longer hides the thinking indicator and its spinner, which had made a reasoning model look as though it had already finished from the first moment it started. [#28559](https://github.com/open-webui/open-webui/issues/28559), [Commit](https://github.com/open-webui/open-webui/commit/b1dc945bd6cf97a7067e603fc1b797301e626fb6)
|
||||
- 🈳 **Conversations compacted too early on llama.cpp.** A conversation served by llama.cpp is no longer shortened at roughly half the size you configured, where its cached input was counted twice, so a chat showing 39,000 tokens was treated as 77,000 against a 70,000 limit. [#28590](https://github.com/open-webui/open-webui/issues/28590), [Commit](https://github.com/open-webui/open-webui/commit/0b27fa5e873c9f3cff7b14e4eeab8321bdd8970c)
|
||||
- ◻️ **Settings tabs spilling past the corner.** Scrolling the list of tabs in settings no longer paints a tab or part of an icon across the dialog's rounded bottom corner, where it appeared to sit outside the dialog. [#27617](https://github.com/open-webui/open-webui/pull/27617), [#27616](https://github.com/open-webui/open-webui/issues/27616)
|
||||
- 🎰 **Settings for a switched-off function.** A function that has been turned off no longer offers its per-user settings, and saving them is refused, where doing so loaded the function's code and stored settings that had no effect. [Commit](https://github.com/open-webui/open-webui/commit/a3a81fee03ba7ec0ceb4d1f456e80a8f4fc2320f)
|
||||
- 🗃 **Shared folders reordering themselves.** The list of folders shared with you now keeps a consistent order, where on PostgreSQL renaming a folder could shuffle the ones beside it. [#28804](https://github.com/open-webui/open-webui/pull/28804)
|
||||
- 📜 **System prompt repeated after a tool call.** On models served by a pipe or manifold, the system prompt is no longer added again each time a tool runs, where it built up one extra copy per round and was sent to the provider that way. [#28739](https://github.com/open-webui/open-webui/pull/28739), [#28736](https://github.com/open-webui/open-webui/issues/28736)
|
||||
- 🔕 **Calendar reminders stopping for everyone.** A single event whose reminder time was stored as something other than a number no longer stops reminders being sent, for that event or for anyone else's, and falls back to the usual reminder window instead. [#28790](https://github.com/open-webui/open-webui/pull/28790)
|
||||
- 🗨 **Attached conversations reaching the model.** A conversation attached to your message is now listed among its attachments, where it was left out entirely and the model was never told it was there. [#28788](https://github.com/open-webui/open-webui/pull/28788)
|
||||
- ↔ **Dragging a side panel closed.** Dragging the chat controls, the note chat, or a channel thread panel closed by its edge no longer floods the browser console with errors and leaves stray handlers behind, and the divider can now be moved with the arrow keys once focused. [#28759](https://github.com/open-webui/open-webui/issues/28759), [Commit](https://github.com/open-webui/open-webui/commit/33dff414e829d10dc6691b1cac7457077fa6799c)
|
||||
- 🎫 **Saving a message with unusual characters on PostgreSQL.** A message carrying characters PostgreSQL will not store outside its text no longer fails to save, where those characters were cleaned from the conversation but passed through raw to the separate message record. [#28820](https://github.com/open-webui/open-webui/pull/28820)
|
||||
- 🔲 **Removing an item in the model editor.** Unticking a tool, skill, action, or filter no longer leaves the next one in the list looking unticked while it is still selected, needing two clicks to remove and passing the same confusion down the list each time. [#28837](https://github.com/open-webui/open-webui/pull/28837), [#28832](https://github.com/open-webui/open-webui/issues/28832)
|
||||
- 📉 **Usage figures drifting upward on clustered setups.** The routine that clears out finished model usage no longer stops for good across the whole cluster after a brief interruption, which had left the usage figures counting models nobody was using and grew the work every disconnection had to do. [#28834](https://github.com/open-webui/open-webui/pull/28834)
|
||||
- 🔇 **Voice mode staying silent with reasoning models.** Voice mode now speaks when the emoji option is on and the model behind it reports its answer as thinking rather than text, where the whole reply went unspoken and nothing reached the speech service at all. [#28724](https://github.com/open-webui/open-webui/pull/28724)
|
||||
- 🏷 **Tags on a chat shared with you.** Opening a chat shared with you, or one in a shared folder, no longer fails to load its tags, and an administrator opening someone else's chat now sees the tags that chat actually carries. [Commit](https://github.com/open-webui/open-webui/commit/7d4747dfd73d7629227b10ae63c6854cc7543bee), [#28767](https://github.com/open-webui/open-webui/issues/28767)
|
||||
- 🔻 **Message box controls in a narrow panel.** Narrowing the note chat panel, or squeezing the chat with a wide controls pane, no longer hides the attach and integrations buttons behind the model name or pushes the send button outside the box; the model name is shortened to make room instead. [#28912](https://github.com/open-webui/open-webui/pull/28912), [#28911](https://github.com/open-webui/open-webui/issues/28911)
|
||||
- ⌛ **Replies that are all thinking and no answer.** A reply from a provider using the responses format that ends while the model is still in its reasoning, with no answer text after it, now finishes normally instead of failing the whole turn and leaving an unreadable error in place of the reply. [#28872](https://github.com/open-webui/open-webui/pull/28872), [#28871](https://github.com/open-webui/open-webui/issues/28871)
|
||||
- 📃 **Word and PowerPoint previews overflowing.** Previewing one of these files now keeps the document inside its frame, with the zoom and slide controls staying put rather than scrolling away, and a presentation opens on its current slide instead of below the visible area. [#28878](https://github.com/open-webui/open-webui/pull/28878), [#28877](https://github.com/open-webui/open-webui/issues/28877)
|
||||
- 🎞 **Workspace models in the admin models list.** Workspace models appear in the admin models list again, so they can be ordered, set as the default, and pinned for everyone; choosing one opens its own editor, where it opened the base model editor and could strip the model's base model, turning it into something else. [Commit](https://github.com/open-webui/open-webui/commit/ccbb3303f2ec5db0b16573bbb665a12f6764da66), [#27702](https://github.com/open-webui/open-webui/issues/27702)
|
||||
- ⌨ **Errors after sending a long message.** With prompt autocompletion on, sending or clearing a message of several paragraphs within a second of typing no longer throws an error in the browser console. [#28824](https://github.com/open-webui/open-webui/pull/28824), [#28823](https://github.com/open-webui/open-webui/issues/28823)
|
||||
- ❌ **Tool calls that failed looking successful.** A tool call that returned an error is now marked as failed with a red cross rather than a green tick, so a reply built on a failed call is easier to spot. [Commit](https://github.com/open-webui/open-webui/commit/f3f76095d18e07a3a944f22a4b25993bbffe90a3), [#28016](https://github.com/open-webui/open-webui/issues/28016)
|
||||
- 🗜 **Download links in a cited source doing nothing.** A link in a citation shown as formatted content now downloads the file when clicked, where it silently did nothing at all. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf), [#28924](https://github.com/open-webui/open-webui/issues/28924)
|
||||
- 🗳 **Web searches failing without saying why.** A web search that fails now explains itself instead of returning nothing at all, which most often happens when a search engine has been selected without its key being configured. [#28942](https://github.com/open-webui/open-webui/pull/28942)
|
||||
- ✅ **Checklists in notes.** A checklist in a note now previews and downloads as a proper checklist, where each item carried a stray second pair of brackets and its text began two lines below the box. [#27671](https://github.com/open-webui/open-webui/pull/27671), [#26067](https://github.com/open-webui/open-webui/issues/26067)
|
||||
- 🧿 **Shortening a conversation with the wrong model.** Choosing to shorten long conversations with the model you are chatting with now does that, where it used the configured task model instead on any instance that has one. [Commit](https://github.com/open-webui/open-webui/commit/5093a9938937153e287db27a671f5ba1fb5d7592), [#27603](https://github.com/open-webui/open-webui/issues/27603)
|
||||
- 🖥 **Stopping a reply after the shared cache restarts.** Stopping a reply now keeps working across a cluster after the shared cache restarts or its connection drops, where the part that carries a stop between instances gave up for good and silently, and only restarting the application brought it back. [Commit](https://github.com/open-webui/open-webui/commit/bf3a58dbcd18ddc2c7f130d8f9529477fe7cb042), [#28909](https://github.com/open-webui/open-webui/issues/28909)
|
||||
- 📼 **Attached links to media and archives.** Attaching a link that leads to something other than a web page, such as a video or an archive, now reads it as the file it is rather than trying to treat it as text. [Commit](https://github.com/open-webui/open-webui/commit/886248de36e3a60c3687d1bee6af110e14110eca)
|
||||
- 🗒 **Editing a workflow from settings.** Opening the code editor for a ComfyUI workflow from the images settings now brings it to the front, where it opened behind the settings dialog and could not be reached at all. [#27648](https://github.com/open-webui/open-webui/pull/27648), [#27647](https://github.com/open-webui/open-webui/issues/27647)
|
||||
- 🖼 **Downloading a generated image.** Downloading an image from its preview now saves the image, where it could silently save a small file containing an authentication error instead, and a download that does fail now says so. [Commit](https://github.com/open-webui/open-webui/commit/2578174637e48cafa4bcb09adbb1b7f4b545a8d5), [#27723](https://github.com/open-webui/open-webui/pull/27723), [#27722](https://github.com/open-webui/open-webui/issues/27722)
|
||||
- 🗂 **Directory sync listing local accounts.** A directory service syncing accounts over SCIM now sees only the accounts that came from a directory, where it also listed and could modify accounts created with a password in Open WebUI itself. [Commit](https://github.com/open-webui/open-webui/commit/fb4f476316a2f83e4d2914d535c19b3440cbc490)
|
||||
- ↕ **Sorting a list of people.** Sorting the admin user list, or a channel's member list, now works when no search term has been entered, where the chosen order was ignored unless something was being searched for. [Commit](https://github.com/open-webui/open-webui/commit/fb4f476316a2f83e4d2914d535c19b3440cbc490)
|
||||
- 🧶 **Text dropped from a reply by a filter.** A filter that rewrites a reply as it streams, or a provider that sends something other than plain text in a chunk, no longer causes that part of the reply to vanish without explanation. [#28840](https://github.com/open-webui/open-webui/pull/28840)
|
||||
- 🏗 **Timers firing twice after a fork.** Branching a conversation that has a timer set no longer leaves the copy able to fire that timer as well. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745)
|
||||
- 🗣 **Sentences skipped in voice mode.** Voice mode now speaks every sentence of a reply, where any sentence that completed in the same piece of the reply as another was silently never read out, which happened routinely with providers that send whole paragraphs at a time. [Commit](https://github.com/open-webui/open-webui/commit/495296346edfcd96a506a22fed4bb5b8faf3cd4d), [#28730](https://github.com/open-webui/open-webui/issues/28730), [#19861](https://github.com/open-webui/open-webui/issues/19861)
|
||||
- 🖊 **Dragging a side panel wider than the window.** A side panel can no longer be dragged so wide that the chat beside it is squeezed away, and dragging one below its minimum width now closes it rather than sticking. [Commit](https://github.com/open-webui/open-webui/commit/ef455fcef9d6cb1136275c26e49bc3c5d6661795), [#28965](https://github.com/open-webui/open-webui/issues/28965)
|
||||
- 🎙 **The wrong model selected when reopening a chat.** Reopening a conversation where a reply was regenerated with a different model now selects the model behind the reply you are looking at, where it picked the one used for the first attempt, which might be a model no longer available. [#27674](https://github.com/open-webui/open-webui/pull/27674), [#25052](https://github.com/open-webui/open-webui/issues/25052), [Commit](https://github.com/open-webui/open-webui/commit/8a170897bad569d93e069226a996942583ebde80)
|
||||
- 🛜 **Coding tools that speak Anthropic's format.** A tool such as Cline pointed at Open WebUI using Anthropic's own message format can reach models again, where every request failed before it was even sent, and once that was corrected the request was rejected by Anthropic for being signed the wrong way. [#27675](https://github.com/open-webui/open-webui/pull/27675), [#27595](https://github.com/open-webui/open-webui/issues/27595), [#27695](https://github.com/open-webui/open-webui/issues/27695)
|
||||
- 👯 **Code in pinned messages.** Opening the pinned messages of a channel now shows the code in those messages, where each block appeared empty and its contents were drawn into the channel behind the dialog instead, doubling them there. [#27740](https://github.com/open-webui/open-webui/pull/27740), [#27739](https://github.com/open-webui/open-webui/issues/27739)
|
||||
- 🧻 **Logs flooded by an unreachable server.** A terminal or tool server that cannot be reached now records one line per attempt rather than a full stack trace, where a few minutes of downtime could fill the log with hundreds of megabytes and drown out everything else. [#27755](https://github.com/open-webui/open-webui/pull/27755), [#27751](https://github.com/open-webui/open-webui/issues/27751), [#27757](https://github.com/open-webui/open-webui/pull/27757), [#27756](https://github.com/open-webui/open-webui/issues/27756)
|
||||
- 🖱 **Clicking inside a chat preview.** Clicking an image or a source in the preview that appears when you hover a chat in the sidebar no longer flashes a viewer open and shut, since the preview is meant only to be read. [#27770](https://github.com/open-webui/open-webui/pull/27770), [#27769](https://github.com/open-webui/open-webui/issues/27769)
|
||||
- 🛢 **Using pgvector with database access by role.** An instance on Amazon RDS that signs in to its database with a temporary credential rather than a stored password now starts when pgvector is the vector store, where the two could not be used together and the container exited on startup. [#27754](https://github.com/open-webui/open-webui/pull/27754), [#27752](https://github.com/open-webui/open-webui/issues/27752)
|
||||
- 📗 **Opening knowledge attached to a model or folder.** Clicking a knowledge item attached to a model or a folder opens it again, so it can be read and its retrieval mode changed between focused retrieval and the whole document, where since 0.11.0 neither was possible outside a chat. [#27686](https://github.com/open-webui/open-webui/pull/27686), [#27684](https://github.com/open-webui/open-webui/issues/27684), [#27801](https://github.com/open-webui/open-webui/issues/27801), [#28825](https://github.com/open-webui/open-webui/issues/28825)
|
||||
- 🎚 **Retrieval mode shown from another item.** The retrieval mode shown when opening a knowledge item is now that item's own, where it could show the setting of whichever item was opened before it. [#27686](https://github.com/open-webui/open-webui/pull/27686), [#27684](https://github.com/open-webui/open-webui/issues/27684), [#27801](https://github.com/open-webui/open-webui/issues/27801), [#28825](https://github.com/open-webui/open-webui/issues/28825)
|
||||
- 🔩 **A new chat shown as nearly full.** The indicator of how full a conversation is now counts tokens the same way the shortening does, where the two read different figures from providers that report both and a fresh chat could appear close to its limit. [#27620](https://github.com/open-webui/open-webui/pull/27620), [#27608](https://github.com/open-webui/open-webui/issues/27608), [Commit](https://github.com/open-webui/open-webui/commit/978d2572140e4fe31ebbc33274f699c71b8cdc29)
|
||||
- 🫱 **Abandoned changes to a group's sharing setting.** Closing the edit dialog for a user group without saving now discards a change to who can share to that group, where the change stayed on screen and was written to the database the next time anything else about the group was saved. [#28076](https://github.com/open-webui/open-webui/pull/28076), [#28075](https://github.com/open-webui/open-webui/issues/28075)
|
||||
- 📛 **Editing the wrong group.** The dialog for editing a user group now stays with the group it was opened for, where a reordering of the list beneath it could leave it saving to a different group. [#28076](https://github.com/open-webui/open-webui/pull/28076), [#28075](https://github.com/open-webui/open-webui/issues/28075)
|
||||
- 🪢 **Signing people out from the identity provider.** A sign-out sent by an identity provider to end someone's session now works, where the check that the message was genuine could fail against providers whose signing keys need the same authentication as everything else, leaving the person signed in. [Commit](https://github.com/open-webui/open-webui/commit/aeda6ff13a25d3b3ba1b303609f35382db22142c)
|
||||
- 🪣 **Files left behind when a knowledge base is emptied.** Emptying a knowledge base now removes the files it held, along with their stored copies and their search data, where all three were left behind with nothing in the interface to clear them. [Commit](https://github.com/open-webui/open-webui/commit/363ad352fec9553469852d111bc0506b896504a6), [#27988](https://github.com/open-webui/open-webui/issues/27988)
|
||||
- ♻ **Blank errors when a tool server's saved sign-in cannot be read.** A tool server whose stored sign-in details cannot be decrypted, which happens when "WEBUI_SECRET_KEY" changes since the key protecting them follows it, now names the server and says to reconnect it, where every startup logged two errors with no message at all. [Commit](https://github.com/open-webui/open-webui/commit/91917b23952af8ca4457f8bb3db70c75ab838fbf), [#28666](https://github.com/open-webui/open-webui/pull/28666), [#28665](https://github.com/open-webui/open-webui/issues/28665)
|
||||
- 🖍 **Clearing the supported media types.** Emptying the supported media types in the documents settings now stays empty, where the previous value came back on the next visit, so images kept being sent to the extraction engine instead of straight to a model that can read them. [#28750](https://github.com/open-webui/open-webui/pull/28750), [#28747](https://github.com/open-webui/open-webui/issues/28747), [Commit](https://github.com/open-webui/open-webui/commit/ecad20b77f9bd700bd818a2f447176f5994320c3)
|
||||
- 🫳 **Dropping a chat where it already was.** Dragging a chat in the sidebar and releasing it where it started no longer reloads the whole sidebar, which took around a dozen requests for a move that changed nothing. [#28664](https://github.com/open-webui/open-webui/pull/28664), [#28663](https://github.com/open-webui/open-webui/issues/28663)
|
||||
- 🖲 **Filtering while on a later page.** Changing a filter in the workspace, such as showing only what you created, now returns to the first page, where the list could come back empty with the page controls gone and no way back. [#28734](https://github.com/open-webui/open-webui/issues/28734), [Commit](https://github.com/open-webui/open-webui/commit/a914868e3c83b08be1399dbae1e4da25a0c00c87)
|
||||
- 🪺 **Workspace counts left behind.** The number beside each workspace tab now follows its list, where creating, copying, importing, or deleting something left the old number in place until you moved to another tab or reloaded, and the tools count ignored its search entirely. [#28983](https://github.com/open-webui/open-webui/pull/28983), [#28981](https://github.com/open-webui/open-webui/issues/28981)
|
||||
- 📞 **Links that start a voice call.** Opening a link that starts a call now starts one, where it opened the controls pane and stopped there, leaving the only way to begin a call from outside the application broken. [#28721](https://github.com/open-webui/open-webui/pull/28721), [#28677](https://github.com/open-webui/open-webui/issues/28677), [Commit](https://github.com/open-webui/open-webui/commit/8c1f3d382470edb81fa1efdf6cf4fb6bb188c460)
|
||||
- 🆔 **Signing in where the provider uses a numeric account id.** Signing in through GitHub, or any other provider that identifies people by a number, works again on PostgreSQL, where every attempt failed outright since 0.10.2, and on SQLite the existing account was not matched; accounts stored the old way are corrected on the next sign-in. [Commit](https://github.com/open-webui/open-webui/commit/a6834f089bc2980fced99a617670e785c988cc9f), [#28954](https://github.com/open-webui/open-webui/pull/28954), [#27760](https://github.com/open-webui/open-webui/issues/27760)
|
||||
- ☎ **Chats opening halfway up.** A conversation whose recent messages are short now opens at the latest message rather than somewhere in the middle, and older messages loaded while scrolling up no longer shift what you were reading. [#28657](https://github.com/open-webui/open-webui/pull/28657), [#28656](https://github.com/open-webui/open-webui/issues/28656)
|
||||
- 🛤 **The terminal picker vanishing mid-reply.** The terminal picker now stays in place while a reply is being written, greyed out until it finishes, where it disappeared from the message box entirely and took the name of the selected terminal with it. [Commit](https://github.com/open-webui/open-webui/commit/7a533d0d5b85981f8c5668636f931f8b4a97d604)
|
||||
- 🎧 **Who a channel says its members are.** The member list of a channel now includes its owner and everyone in a group that was granted access, where the owner never appeared and granting both a person and a group they were not in listed nobody at all while the count beside it said two. [#28289](https://github.com/open-webui/open-webui/pull/28289), [#28288](https://github.com/open-webui/open-webui/issues/28288), [Commit](https://github.com/open-webui/open-webui/commit/e3e82b14714661ba5bd24e2c47e42cef5594b132)
|
||||
- ⛳ **Embedding servers behind a password.** An embedding server protected by a username and password rather than a key now works, where an empty key still sent an authorisation header, which such a server rejected and which made document uploads fail. [#28684](https://github.com/open-webui/open-webui/pull/28684), [#28683](https://github.com/open-webui/open-webui/issues/28683), [Commit](https://github.com/open-webui/open-webui/commit/97466deea105d6fdde60bf4d9401703fd32e03e9)
|
||||
- ✒ **Note edits lost without warning.** Typing in a note now reaches the database, where a save already waiting could be cancelled without a replacement, leaving the editor showing text that was never stored and nothing to say so; this affected notes written by a model or through the API, and any instance without Redis after a restart. [#28669](https://github.com/open-webui/open-webui/pull/28669), [#28667](https://github.com/open-webui/open-webui/issues/28667)
|
||||
- 🗄 **Browsing outside the home directory in a terminal.** A terminal server set to allow browsing the whole filesystem can be browsed above the home directory again, where the file panel pinned itself there and opening a file elsewhere quietly did nothing. [#29006](https://github.com/open-webui/open-webui/pull/29006), [#29000](https://github.com/open-webui/open-webui/issues/29000)
|
||||
- 🏞 **Clearing a model's picture.** A model's picture can be reset to the default logo again, where since 0.11.0 a custom one could only ever be replaced. [#29007](https://github.com/open-webui/open-webui/pull/29007), [#27685](https://github.com/open-webui/open-webui/issues/27685), [Commit](https://github.com/open-webui/open-webui/commit/06e7aac219b3027a9d578624c473b041ad26a692)
|
||||
- 👤 **Fallback profile pictures.** A profile picture that fails to load, such as one belonging to a deleted account, now falls back to the default avatar instead of showing clipped placeholder text beside the message. [#28270](https://github.com/open-webui/open-webui/pull/28270), [#28269](https://github.com/open-webui/open-webui/issues/28269)
|
||||
- ⏱️ **Unanswered prompts in tools.** On deployments that set "WEBSOCKET_EVENT_CALLER_TIMEOUT", a question a tool asks you that goes unanswered now reports a timeout rather than an empty reply, and waiting too long no longer risks disconnecting a tab that is still open. [#28311](https://github.com/open-webui/open-webui/pull/28311)
|
||||
- 👪 **Group member counts updating.** Adding or removing someone from a group in the admin panel now updates that group's member count straight away, where it stayed at the old number until the page was reloaded. [Commit](https://github.com/open-webui/open-webui/commit/18bf0ade7b35fb25ff7920ad3713624d93f04401)
|
||||
- 🕳️ **Blank messages left in a conversation.** Streamed events that never fill in an item no longer leave an empty assistant message saved in the conversation and sent back to the model on every later turn. [Commit](https://github.com/open-webui/open-webui/commit/2a0274a0a039dbe0a1ad4d24003b085aae7b896b)
|
||||
- 📓 **Notes opening blank.** A note whose shared editing session has not been started yet now opens with its stored content even when several people open it at once, where previously anyone but a lone first viewer got an empty document. [Commit](https://github.com/open-webui/open-webui/commit/5078d987f83943671f1d23ebe66bcc8d2af902c3)
|
||||
- 🤫 **Replies stopping silently after a tool ran.** When a provider rejects the follow-up request made after a tool finishes, the reason is now shown in the chat instead of the reply simply ending with nothing said. [Commit](https://github.com/open-webui/open-webui/commit/a610d77137fabf60a2e0daa961a8ef8f7320293a), [#28633](https://github.com/open-webui/open-webui/issues/28633)
|
||||
- 🎟️ **Connections whose tags were saved as plain text.** A connection with tags stored as plain text no longer breaks its editor panel or silently blanks the tags on every model coming from it. [Commit](https://github.com/open-webui/open-webui/commit/8be4c5fa6a849a9ff4a74f6a2870a115a836e68a), [#28749](https://github.com/open-webui/open-webui/issues/28749)
|
||||
- 🔌 **Stream filters on direct API calls.** A filter reading a streamed event as an object now works on requests made straight to the chat completions endpoint, matching every other path, where it used to raise and end the reply partway. [Commit](https://github.com/open-webui/open-webui/commit/684111715f742f56a3c7efac9eb7a1e68e54a3ec)
|
||||
- 🔇 **Filter failures that said nothing.** When a filter's outlet or stream hook raises, the failure is now reported with the filter's name and a traceback at the default log level, where it was swallowed and left plugin authors with nothing to go on. [Commit](https://github.com/open-webui/open-webui/commit/35fbde0a3fb303b7076b03465b1a2b0ba832df53)
|
||||
- 🪜 **Falling back when a base model is gone.** Chatting with a workspace model whose base model has been removed now falls back to the default model for everyone, where the fallback previously applied only for administrators and anyone else was refused whenever that base model had no workspace entry of its own, such as one supplied by a pipe. [Commit](https://github.com/open-webui/open-webui/commit/20fe43d9da621957c48fc92104bc8f1cc0d691b7)
|
||||
- 🧠 **Conversations breaking after a model switch.** Switching to a different model once a reasoning model has answered no longer leaves the conversation unusable, where the earlier model's stored reasoning was replayed to a provider that then rejected every later request. [Commit](https://github.com/open-webui/open-webui/commit/c4b3e6840f34a5d267bc12c0ec1a82156a324d45), [#28240](https://github.com/open-webui/open-webui/issues/28240)
|
||||
- 👻 **Messages disappearing from a conversation.** Two things saving the same conversation at once no longer discard each other's changes, where a message could vanish from the screen while still counting toward the model's context and a file attached during a reply was lost on the next save. [Commit](https://github.com/open-webui/open-webui/commit/1c13fedb16e74c5888c52ccd928a5eb5bbb1068d), [#28742](https://github.com/open-webui/open-webui/issues/28742)
|
||||
- 🧬 **Workspace models pointing at themselves.** A workspace model whose base model was set to its own identifier is now saved without that reference, where the entry was thrown away while models were being combined so none of its settings ever took effect. [Commit](https://github.com/open-webui/open-webui/commit/eadce55e343df69520e6bc86dbbdfdcdfdcafb30), [#28952](https://github.com/open-webui/open-webui/issues/28952), [#28923](https://github.com/open-webui/open-webui/issues/28923)
|
||||
- 📭 **A short model list sticking on every worker.** When one worker briefly reports fewer models than it should, that shorter list is no longer left in place for the whole deployment, where chatting with one of the missing models failed on every server until something restarted. [Commit](https://github.com/open-webui/open-webui/commit/6330350a406d8c1fd603f725e59a88324c8e2256), [#28777](https://github.com/open-webui/open-webui/issues/28777)
|
||||
- 📋 **Copying from the terminal file browser.** Copying a file path or a file's contents now works again, where it silently did nothing on deployments the browser does not treat as a secure origin. [Commit](https://github.com/open-webui/open-webui/commit/aa3d56961049618b28717d87acff1d9360cad47a), [Commit](https://github.com/open-webui/open-webui/commit/2f97c9fce36b3794d14c925086eca78ff92adf4a), [#29015](https://github.com/open-webui/open-webui/issues/29015)
|
||||
- 🗺️ **Browsing the web through Microsoft Web IQ.** Fetching a page with the web loader set to Microsoft Web IQ now works, where every attempt failed before a single request was made and had done so ever since that loader was added. [Commit](https://github.com/open-webui/open-webui/commit/6dcc2d52692c3fa1993ff1b3ae84c07d35fd9f6b), [Commit](https://github.com/open-webui/open-webui/commit/140d2cf4b59e71d2e9f4986d4d8649fccb47c83d), [#28688](https://github.com/open-webui/open-webui/issues/28688)
|
||||
- 🔛 **Enable or disable all automations only reaching the ones on screen.** Turning every automation on or off now covers every automation matching your current search and filter, where it only ever touched the ones loaded on the page you were looking at and left the rest running as they were. [Commit](https://github.com/open-webui/open-webui/commit/f4a0d3c9734d1662a3f78891f21934f2b82aed1e)
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
|
||||
- 🏋️ **What "THREAD_POOL_SIZE" now sizes.** The setting now governs both of the pools that background work runs in, where it previously governed only one and the other, carrying most of the blocking work including knowledge searches, sign-ins and file storage, was fixed at a small ceiling no setting could raise, so an instance that set it high will now use more threads than before, up to twice the configured value across the two pools, and one that relied on the old ceiling to hold thread use down should review it. [Commit](https://github.com/open-webui/open-webui/commit/4ec6ee14418edd04eaba9e34bd5868453f61df40), [#28168](https://github.com/open-webui/open-webui/issues/28168)
|
||||
- 💾 **Saving replies as they stream.** The "ENABLE_REALTIME_CHAT_SAVE" setting no longer has any effect, because a reply in progress is now held outside the database and written once when it finishes. [Commit](https://github.com/open-webui/open-webui/commit/a1579a01ff43cacb357269707d36267ad35e01d6)
|
||||
- 🎭 **Playwright web loader egress.** When a page is fetched with the Playwright loader, the page's own requests for its images, scripts, and stylesheets are now made by the Open WebUI backend instead of by the browser, so administrators using a remote browser through "PLAYWRIGHT_WS_URL" should expect that traffic to leave from the backend's address rather than the browser host, those using a private certificate authority should expect it to be trusted through "AIOHTTP_CLIENT_SSL_CERT_FILE" rather than the browser's own store, and those who set a proxy on the loader should know it no longer applies to these requests, which follow the environment's proxy settings instead. [#28634](https://github.com/open-webui/open-webui/pull/28634)
|
||||
- 🐌 **Slower Playwright page loads without async.** A page fetched with the Playwright loader on the synchronous path now fetches its images, scripts, and stylesheets one at a time rather than together, which in the change's own measurements took a page with thirty assets from 2.0 to 3.0 seconds, and one with eight slow assets from 1.1 to 4.5 seconds; the asynchronous path is unaffected. [#28634](https://github.com/open-webui/open-webui/pull/28634)
|
||||
- 🐳 **Test-only packages removed from the image.** The container no longer ships pytest, pytest-docker, the Docker SDK, or netcat, none of which anything in Open WebUI used, so the image is smaller; anyone whose own tools or functions relied on those being present will need to install them themselves. [#28726](https://github.com/open-webui/open-webui/pull/28726)
|
||||
- 🛡 **Forms in embedded pages now work by default.** A page shown inside a chat, such as an artifact or an HTML preview, may now submit forms unless you turn that off, where it was blocked unless you turned it on. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf)
|
||||
- 🅰 **Connection prefixes now show in model names.** A connection's prefix appears in the name shown in the model picker, not only in its identifier, where whether it did depended on the shape of the provider's reply and so worked on some connections and not others; models on prefixed connections will now read differently than before. [#28950](https://github.com/open-webui/open-webui/pull/28950), [#28929](https://github.com/open-webui/open-webui/issues/28929)
|
||||
- 📊 **Default usage statistics range.** Usage statistics now cover the past two years by default for everyone, instead of starting from the date the account was created. [Commit](https://github.com/open-webui/open-webui/commit/8dbbc206c5a0706789722c42827479e5db10bb2b)
|
||||
|
||||
## [0.11.0] - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Examples of behavior that contribute to a positive and professional community in
|
|||
|
||||
- **Respecting others.** Be considerate, listen actively, and engage with empathy toward others' viewpoints and experiences.
|
||||
- **Constructive feedback.** Provide actionable, thoughtful, and respectful feedback that helps improve the project and encourages collaboration. Avoid unproductive negativity or hypercriticism.
|
||||
- **Recognizing volunteer contributions.** Appreciate that contributors dedicate their free time and resources selflessly. Approach them with gratitude and patience.
|
||||
- **Recognizing volunteer contributions.** Appreciate that **contributors dedicate their free time and resources selflessly**. Approach them with gratitude and patience.
|
||||
- **Focusing on shared goals.** Collaborate in ways that prioritize the health, success, and sustainability of the community over individual agendas.
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
|
@ -32,6 +32,7 @@ Examples of unacceptable behavior include:
|
|||
- **Entitlement, demand, or aggression toward contributors.** Volunteers are under no obligation to provide immediate or personalized support. Rude or dismissive behavior will not be tolerated.
|
||||
- **Unproductive or destructive behavior.** This includes venting frustration as hostility ("tantrums"), hypercriticism, attention-seeking negativity, or anything that distracts from the project's goals.
|
||||
- **Spamming and promotional exploitation.** Sharing irrelevant product promotions or self-promotion in the community is not allowed unless it directly contributes value to the discussion.
|
||||
- Posting low-effort, hard to read, essay-length AI generated comments or other forms of low-quality, hard to parse content that puts the burden of understanding on the reader.
|
||||
|
||||
### Feedback and Community Engagement
|
||||
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -126,7 +126,7 @@ RUN chown -R $UID:$GID /app $HOME
|
|||
# Install common system dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git build-essential pandoc gcc netcat-openbsd curl jq ca-certificates \
|
||||
git build-essential pandoc gcc curl jq ca-certificates \
|
||||
libmariadb-dev \
|
||||
python3-dev \
|
||||
ffmpeg libsm6 libxext6 zstd \
|
||||
|
|
@ -149,7 +149,7 @@ RUN set -e; \
|
|||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
|
||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \
|
||||
else \
|
||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir; \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir; \
|
||||
|
|
@ -158,12 +158,18 @@ RUN set -e; \
|
|||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
|
||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \
|
||||
fi; \
|
||||
fi; \
|
||||
mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \
|
||||
if [ -d /app/backend/data/cache ]; then chmod -R a+rX /app/backend/data/cache; fi; \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
# Optional: PPTX parsing through unstructured may need spaCy's English model.
|
||||
# Keep this out of the default image to avoid the extra image bloat; deployments
|
||||
# with read-only site-packages can uncomment it and bake the model in.
|
||||
# RUN python -m spacy download en_core_web_sm
|
||||
|
||||
# Install Ollama if requested
|
||||
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
||||
date +%s > /tmp/ollama_build_hash && \
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
|
||||
PORT="${PORT:-8080}"
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}" --reload
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ def version_callback(value: bool) -> None:
|
|||
if value:
|
||||
from open_webui.env import VERSION
|
||||
|
||||
# LICENSE covers this Open WebUI CLI identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
typer.echo(f'Open WebUI version: {VERSION}')
|
||||
raise typer.Exit()
|
||||
|
||||
|
|
@ -71,7 +74,7 @@ def serve(
|
|||
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
|
||||
|
||||
import open_webui.main # noqa: F401
|
||||
from open_webui.env import UVICORN_WORKERS # Import the workers setting
|
||||
from open_webui.env import UVICORN_WORKERS, UVICORN_WS_PER_MESSAGE_DEFLATE
|
||||
|
||||
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
|
||||
# which is incompatible with psycopg v3 async. Setting loop='none' lets
|
||||
|
|
@ -84,6 +87,7 @@ def serve(
|
|||
port=port,
|
||||
forwarded_allow_ips='*',
|
||||
workers=UVICORN_WORKERS,
|
||||
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
|
|
@ -94,12 +98,15 @@ def dev(
|
|||
port: int = 8080,
|
||||
reload: bool = True,
|
||||
):
|
||||
from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE
|
||||
|
||||
uvicorn.run(
|
||||
'open_webui.main:app',
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
forwarded_allow_ips='*',
|
||||
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
|
@ -20,6 +19,7 @@ from pydantic import BaseModel
|
|||
from open_webui.env import (
|
||||
DATA_DIR,
|
||||
DATABASE_URL,
|
||||
ENABLE_ADMIN_CHAT_ACCESS,
|
||||
ENABLE_DB_MIGRATIONS,
|
||||
ENV,
|
||||
FRONTEND_BUILD_DIR,
|
||||
|
|
@ -35,11 +35,12 @@ from open_webui.env import (
|
|||
log,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
|
||||
async def seed_registered_defaults():
|
||||
await Config.rename_prefix('rag.web', 'web')
|
||||
await Config.repair_flattened_dict_configs()
|
||||
await Config.repair_config_rows()
|
||||
await Config.seed_defaults(DEFAULT_CONFIG)
|
||||
|
||||
|
||||
|
|
@ -84,7 +85,7 @@ async def import_legacy_config_json():
|
|||
if not os.path.exists(f'{DATA_DIR}/config.json'):
|
||||
return
|
||||
with open(f'{DATA_DIR}/config.json', 'r') as _f:
|
||||
await Config.upsert(json.load(_f))
|
||||
await Config.upsert(JSONCodec.loads(_f.read()))
|
||||
os.rename(f'{DATA_DIR}/config.json', f'{DATA_DIR}/old_config.json')
|
||||
|
||||
|
||||
|
|
@ -114,6 +115,9 @@ for file_path in (FRONTEND_BUILD_DIR / 'static').glob('**/*'):
|
|||
except Exception as e:
|
||||
logging.error(f'An error occurred: {e}')
|
||||
|
||||
# LICENSE covers copied Open WebUI logo/favicon assets.
|
||||
# Do not alter, remove, obscure, or replace them except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
frontend_favicon = FRONTEND_BUILD_DIR / 'static' / 'favicon.png'
|
||||
|
||||
if frontend_favicon.exists():
|
||||
|
|
@ -181,6 +185,9 @@ CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|||
# CUSTOM_NAME (Legacy)
|
||||
####################################
|
||||
|
||||
# LICENSE covers this legacy Open WebUI branding path.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
CUSTOM_NAME = os.getenv('CUSTOM_NAME', '')
|
||||
|
||||
if CUSTOM_NAME:
|
||||
|
|
@ -270,9 +277,9 @@ def _resolve_ollama_base_url(url: str) -> str:
|
|||
|
||||
if not default.result() and fallback.result():
|
||||
url = url.replace(':11434', ':12434')
|
||||
log.info(f'Ollama port 11434 unreachable on {host}, falling back to 12434')
|
||||
log.info('Ollama port 11434 unreachable on %s, falling back to 12434', host)
|
||||
elif not default.result():
|
||||
log.info(f'Ollama ports 11434 and 12434 both unreachable on {host}')
|
||||
log.info('Ollama ports 11434 and 12434 both unreachable on %s', host)
|
||||
|
||||
return url
|
||||
|
||||
|
|
@ -293,12 +300,12 @@ OLLAMA_API_CONFIGS = {}
|
|||
_ollama_api_configs = os.getenv('OLLAMA_API_CONFIGS', '')
|
||||
if _ollama_api_configs:
|
||||
try:
|
||||
parsed = json.loads(_ollama_api_configs)
|
||||
parsed = JSONCodec.loads(_ollama_api_configs)
|
||||
if isinstance(parsed, dict):
|
||||
OLLAMA_API_CONFIGS = parsed
|
||||
else:
|
||||
log.warning('OLLAMA_API_CONFIGS must be a JSON object, ignoring')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except (JSONCodec.JSONDecodeError, TypeError):
|
||||
log.warning('OLLAMA_API_CONFIGS is not valid JSON, ignoring')
|
||||
|
||||
####################################
|
||||
|
|
@ -340,12 +347,12 @@ OPENAI_API_CONFIGS = {}
|
|||
_openai_api_configs = os.getenv('OPENAI_API_CONFIGS', '')
|
||||
if _openai_api_configs:
|
||||
try:
|
||||
parsed = json.loads(_openai_api_configs)
|
||||
parsed = JSONCodec.loads(_openai_api_configs)
|
||||
if isinstance(parsed, dict):
|
||||
OPENAI_API_CONFIGS = parsed
|
||||
else:
|
||||
log.warning('OPENAI_API_CONFIGS must be a JSON object, ignoring')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except (JSONCodec.JSONDecodeError, TypeError):
|
||||
log.warning('OPENAI_API_CONFIGS is not valid JSON, ignoring')
|
||||
|
||||
# Get the actual OpenAI API key based on the base URL
|
||||
|
|
@ -369,7 +376,7 @@ ENABLE_BASE_MODELS_CACHE = os.getenv('ENABLE_BASE_MODELS_CACHE', 'False').lower(
|
|||
####################################
|
||||
|
||||
try:
|
||||
tool_server_connections = json.loads(os.getenv('TOOL_SERVER_CONNECTIONS', '[]'))
|
||||
tool_server_connections = JSONCodec.loads(os.getenv('TOOL_SERVER_CONNECTIONS', '[]'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading TOOL_SERVER_CONNECTIONS: {e}')
|
||||
tool_server_connections = []
|
||||
|
|
@ -383,12 +390,12 @@ OAUTH_CLIENT_TIMEOUT = os.getenv('OAUTH_CLIENT_TIMEOUT', '')
|
|||
# TERMINAL_SERVER
|
||||
####################################
|
||||
|
||||
terminal_server_connections = json.loads(os.getenv('TERMINAL_SERVER_CONNECTIONS', '[]'))
|
||||
terminal_server_connections = JSONCodec.loads(os.getenv('TERMINAL_SERVER_CONNECTIONS', '[]'))
|
||||
|
||||
TERMINAL_SERVER_CONNECTIONS = terminal_server_connections
|
||||
|
||||
try:
|
||||
TERMINAL_PROXY_HEADERS = json.loads(os.getenv('TERMINAL_PROXY_HEADERS', '{}'))
|
||||
TERMINAL_PROXY_HEADERS = JSONCodec.loads(os.getenv('TERMINAL_PROXY_HEADERS', '{}'))
|
||||
except Exception:
|
||||
TERMINAL_PROXY_HEADERS = {}
|
||||
|
||||
|
|
@ -805,7 +812,7 @@ if VECTOR_DB == 'oracle23ai':
|
|||
'Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication.'
|
||||
)
|
||||
|
||||
log.info(f'VECTOR_DB: {VECTOR_DB}')
|
||||
log.info('VECTOR_DB: %s', VECTOR_DB)
|
||||
|
||||
# S3 Vector
|
||||
S3_VECTOR_BUCKET_NAME = os.getenv('S3_VECTOR_BUCKET_NAME', None)
|
||||
|
|
@ -894,8 +901,8 @@ MINERU_API_KEY = os.getenv('MINERU_API_KEY', '')
|
|||
|
||||
mineru_params = os.getenv('MINERU_PARAMS', '')
|
||||
try:
|
||||
mineru_params = json.loads(mineru_params)
|
||||
except json.JSONDecodeError:
|
||||
mineru_params = JSONCodec.loads(mineru_params)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
mineru_params = {}
|
||||
|
||||
MINERU_PARAMS = mineru_params
|
||||
|
|
@ -908,8 +915,8 @@ EXTERNAL_DOCUMENT_LOADER_API_KEY = os.getenv('EXTERNAL_DOCUMENT_LOADER_API_KEY',
|
|||
|
||||
external_document_loader_headers = os.getenv('EXTERNAL_DOCUMENT_LOADER_HEADERS', '')
|
||||
try:
|
||||
external_document_loader_headers = json.loads(external_document_loader_headers)
|
||||
except json.JSONDecodeError:
|
||||
external_document_loader_headers = JSONCodec.loads(external_document_loader_headers)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
external_document_loader_headers = {}
|
||||
if not isinstance(external_document_loader_headers, dict):
|
||||
external_document_loader_headers = {}
|
||||
|
|
@ -918,14 +925,16 @@ EXTERNAL_DOCUMENT_LOADER_HEADERS = external_document_loader_headers
|
|||
|
||||
TIKA_SERVER_URL = os.getenv('TIKA_SERVER_URL', 'http://tika:9998')
|
||||
|
||||
TIKA_SERVER_VERSION = os.getenv('TIKA_SERVER_VERSION', '3')
|
||||
|
||||
DOCLING_SERVER_URL = os.getenv('DOCLING_SERVER_URL', 'http://docling:5001')
|
||||
|
||||
DOCLING_API_KEY = os.getenv('DOCLING_API_KEY', '')
|
||||
|
||||
docling_params = os.getenv('DOCLING_PARAMS', '')
|
||||
try:
|
||||
docling_params = json.loads(docling_params)
|
||||
except json.JSONDecodeError:
|
||||
docling_params = JSONCodec.loads(docling_params)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
docling_params = {}
|
||||
|
||||
DOCLING_PARAMS = docling_params
|
||||
|
|
@ -966,6 +975,8 @@ RAG_FILE_MAX_COUNT = int(os.getenv('RAG_FILE_MAX_COUNT')) if os.getenv('RAG_FILE
|
|||
|
||||
RAG_FILE_MAX_SIZE = int(os.getenv('RAG_FILE_MAX_SIZE')) if os.getenv('RAG_FILE_MAX_SIZE') else None
|
||||
|
||||
ENABLE_KNOWLEDGE_FILE_RETENTION = os.getenv('ENABLE_KNOWLEDGE_FILE_RETENTION', 'False').lower() == 'true'
|
||||
|
||||
RAG_FILE_CONTENT_SEARCH_MAX_CHARS = int(os.getenv('RAG_FILE_CONTENT_SEARCH_MAX_CHARS', str(64 * 1024 * 1024)))
|
||||
|
||||
FILE_IMAGE_COMPRESSION_WIDTH = (
|
||||
|
|
@ -988,7 +999,7 @@ PDF_EXTRACT_IMAGES = os.getenv('PDF_EXTRACT_IMAGES', 'False').lower() == 'true'
|
|||
PDF_LOADER_MODE = os.getenv('PDF_LOADER_MODE', 'page')
|
||||
|
||||
RAG_EMBEDDING_MODEL = os.getenv('RAG_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2')
|
||||
log.info(f'Embedding model set: {RAG_EMBEDDING_MODEL}')
|
||||
log.info('Embedding model set: %s', RAG_EMBEDDING_MODEL)
|
||||
|
||||
RAG_TOKENIZER_MODEL = os.getenv('RAG_TOKENIZER_MODEL', '')
|
||||
|
||||
|
|
@ -1016,7 +1027,7 @@ RAG_RERANKING_ENGINE = os.getenv('RAG_RERANKING_ENGINE', '')
|
|||
|
||||
RAG_RERANKING_MODEL = os.getenv('RAG_RERANKING_MODEL', '')
|
||||
if RAG_RERANKING_MODEL != '':
|
||||
log.info(f'Reranking model set: {RAG_RERANKING_MODEL}')
|
||||
log.info('Reranking model set: %s', RAG_RERANKING_MODEL)
|
||||
|
||||
|
||||
RAG_RERANKING_MODEL_AUTO_UPDATE = (
|
||||
|
|
@ -1100,12 +1111,26 @@ ENABLE_LOCAL_WEB_FETCH = (
|
|||
ENABLE_RAG_LOCAL_WEB_FETCH = ENABLE_LOCAL_WEB_FETCH
|
||||
|
||||
|
||||
# Operators extend this through WEB_FETCH_FILTER_LIST.
|
||||
DEFAULT_WEB_FETCH_FILTER_LIST = [
|
||||
'!169.254.169.254',
|
||||
'!fd00:ec2::254',
|
||||
'!metadata.google.internal',
|
||||
'!metadata.azure.com',
|
||||
'!100.100.100.200',
|
||||
'!168.63.129.16', # Azure platform channel, reachable from every Azure VM
|
||||
'!192.88.99.0/24', # 6to4 relay anycast, deprecated by RFC 7526
|
||||
'!224.0.0.0/4', # IPv4 multicast
|
||||
'!::ffff:0:0:0/96', # IPv4-translated (SIIT, RFC 2765), never routed
|
||||
'!64:ff9b:1::/48', # NAT64 local-use prefix, RFC 8215, not a public destination
|
||||
'!100:0:0:1::/64', # dummy prefix, RFC 9780
|
||||
'!2001:1::1', # PCP anycast, RFC 7723, answered by the local network's own edge device
|
||||
'!2001:1::2', # TURN anycast, RFC 8155, likewise
|
||||
'!2001:20::/28', # ORCHIDv2, RFC 7343, never routed
|
||||
'!2001:30::/28', # DRIP, RFC 9374, never routed
|
||||
'!5f00::/16', # SRv6 SIDs, RFC 9602, internal to one segment routing domain
|
||||
'!fec0::/10', # IPv6 site-local, deprecated by RFC 3879
|
||||
'!ff00::/8', # IPv6 multicast
|
||||
]
|
||||
|
||||
web_fetch_filter_list = os.getenv('WEB_FETCH_FILTER_LIST', '')
|
||||
|
|
@ -1148,7 +1173,7 @@ WEB_SEARCH_RESULT_COUNT = int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3'))
|
|||
|
||||
|
||||
try:
|
||||
web_search_domain_filter_list = json.loads(os.getenv('WEB_SEARCH_DOMAIN_FILTER_LIST', '[]'))
|
||||
web_search_domain_filter_list = JSONCodec.loads(os.getenv('WEB_SEARCH_DOMAIN_FILTER_LIST', '[]'))
|
||||
except Exception as e:
|
||||
web_search_domain_filter_list = [
|
||||
# "wikipedia.com",
|
||||
|
|
@ -1297,8 +1322,8 @@ LINKUP_API_KEY = os.getenv('LINKUP_API_KEY', '')
|
|||
|
||||
linkup_search_params = os.getenv('LINKUP_SEARCH_PARAMS', '')
|
||||
try:
|
||||
linkup_search_params = json.loads(linkup_search_params)
|
||||
except json.JSONDecodeError:
|
||||
linkup_search_params = JSONCodec.loads(linkup_search_params)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
linkup_search_params = {}
|
||||
|
||||
LINKUP_SEARCH_PARAMS = linkup_search_params
|
||||
|
|
@ -1330,8 +1355,8 @@ AUTOMATIC1111_API_AUTH = os.getenv('AUTOMATIC1111_API_AUTH', '')
|
|||
|
||||
automatic1111_params = os.getenv('AUTOMATIC1111_PARAMS', '')
|
||||
try:
|
||||
automatic1111_params = json.loads(automatic1111_params)
|
||||
except json.JSONDecodeError:
|
||||
automatic1111_params = JSONCodec.loads(automatic1111_params)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
automatic1111_params = {}
|
||||
|
||||
AUTOMATIC1111_PARAMS = automatic1111_params
|
||||
|
|
@ -1455,8 +1480,8 @@ COMFYUI_WORKFLOW = os.getenv('COMFYUI_WORKFLOW', COMFYUI_DEFAULT_WORKFLOW)
|
|||
|
||||
comfyui_workflow_nodes = os.getenv('COMFYUI_WORKFLOW_NODES', '')
|
||||
try:
|
||||
comfyui_workflow_nodes = json.loads(comfyui_workflow_nodes)
|
||||
except json.JSONDecodeError:
|
||||
comfyui_workflow_nodes = JSONCodec.loads(comfyui_workflow_nodes)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
comfyui_workflow_nodes = []
|
||||
|
||||
COMFYUI_WORKFLOW_NODES = comfyui_workflow_nodes
|
||||
|
|
@ -1468,8 +1493,8 @@ IMAGES_OPENAI_API_KEY = os.getenv('IMAGES_OPENAI_API_KEY', OPENAI_API_KEY)
|
|||
|
||||
images_openai_params = os.getenv('IMAGES_OPENAI_PARAMS', '')
|
||||
try:
|
||||
images_openai_params = json.loads(images_openai_params)
|
||||
except json.JSONDecodeError:
|
||||
images_openai_params = JSONCodec.loads(images_openai_params)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
images_openai_params = {}
|
||||
|
||||
|
||||
|
|
@ -1507,8 +1532,8 @@ IMAGES_EDIT_COMFYUI_WORKFLOW = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW', '')
|
|||
|
||||
images_edit_comfyui_workflow_nodes = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW_NODES', '')
|
||||
try:
|
||||
images_edit_comfyui_workflow_nodes = json.loads(images_edit_comfyui_workflow_nodes)
|
||||
except json.JSONDecodeError:
|
||||
images_edit_comfyui_workflow_nodes = JSONCodec.loads(images_edit_comfyui_workflow_nodes)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
images_edit_comfyui_workflow_nodes = []
|
||||
|
||||
IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = images_edit_comfyui_workflow_nodes
|
||||
|
|
@ -1582,8 +1607,8 @@ AUDIO_TTS_OPENAI_API_KEY = os.getenv('AUDIO_TTS_OPENAI_API_KEY', OPENAI_API_KEY)
|
|||
|
||||
audio_tts_openai_params = os.getenv('AUDIO_TTS_OPENAI_PARAMS', '')
|
||||
try:
|
||||
audio_tts_openai_params = json.loads(audio_tts_openai_params)
|
||||
except json.JSONDecodeError:
|
||||
audio_tts_openai_params = JSONCodec.loads(audio_tts_openai_params)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
audio_tts_openai_params = {}
|
||||
|
||||
AUDIO_TTS_OPENAI_PARAMS = audio_tts_openai_params
|
||||
|
|
@ -1635,7 +1660,7 @@ DEFAULT_MODELS = os.getenv('DEFAULT_MODELS', None)
|
|||
DEFAULT_PINNED_MODELS = os.getenv('DEFAULT_PINNED_MODELS', None)
|
||||
|
||||
try:
|
||||
default_prompt_suggestions = json.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]'))
|
||||
default_prompt_suggestions = JSONCodec.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}')
|
||||
default_prompt_suggestions = []
|
||||
|
|
@ -1673,7 +1698,7 @@ if default_prompt_suggestions == []:
|
|||
DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions
|
||||
|
||||
try:
|
||||
model_order_list = json.loads(os.getenv('MODEL_ORDER_LIST', '[]'))
|
||||
model_order_list = JSONCodec.loads(os.getenv('MODEL_ORDER_LIST', '[]'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading MODEL_ORDER_LIST: {e}')
|
||||
model_order_list = []
|
||||
|
|
@ -1681,7 +1706,7 @@ except Exception as e:
|
|||
MODEL_ORDER_LIST = model_order_list
|
||||
|
||||
try:
|
||||
default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}'))
|
||||
default_model_metadata = JSONCodec.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}')
|
||||
default_model_metadata = {}
|
||||
|
|
@ -1689,13 +1714,22 @@ except Exception as e:
|
|||
DEFAULT_MODEL_METADATA = default_model_metadata
|
||||
|
||||
try:
|
||||
default_model_params = json.loads(os.getenv('DEFAULT_MODEL_PARAMS', '{}'))
|
||||
default_model_params = JSONCodec.loads(os.getenv('DEFAULT_MODEL_PARAMS', '{}'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}')
|
||||
default_model_params = {}
|
||||
|
||||
DEFAULT_MODEL_PARAMS = default_model_params
|
||||
|
||||
|
||||
try:
|
||||
default_interface_settings = JSONCodec.loads(os.getenv('DEFAULT_INTERFACE_SETTINGS', '{}'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading DEFAULT_INTERFACE_SETTINGS: {e}')
|
||||
default_interface_settings = {}
|
||||
|
||||
DEFAULT_INTERFACE_SETTINGS = default_interface_settings if isinstance(default_interface_settings, dict) else {}
|
||||
|
||||
DEFAULT_USER_ROLE = os.getenv('DEFAULT_USER_ROLE', 'pending')
|
||||
|
||||
DEFAULT_GROUP_ID = os.getenv('DEFAULT_GROUP_ID', '')
|
||||
|
|
@ -2032,7 +2066,7 @@ ENABLE_USER_STATUS = os.getenv('ENABLE_USER_STATUS', 'True').lower() == 'true'
|
|||
|
||||
ENABLE_EVALUATION_ARENA_MODELS = os.getenv('ENABLE_EVALUATION_ARENA_MODELS', 'True').lower() == 'true'
|
||||
try:
|
||||
evaluation_arena_models = json.loads(os.getenv('EVALUATION_ARENA_MODELS', '[]'))
|
||||
evaluation_arena_models = JSONCodec.loads(os.getenv('EVALUATION_ARENA_MODELS', '[]'))
|
||||
if not isinstance(evaluation_arena_models, list) or not all(
|
||||
isinstance(model, dict) for model in evaluation_arena_models
|
||||
):
|
||||
|
|
@ -2047,6 +2081,9 @@ DEFAULT_ARENA_MODEL = {
|
|||
'id': 'arena-model',
|
||||
'name': 'Arena Model',
|
||||
'meta': {
|
||||
# LICENSE covers this Open WebUI fallback logo.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
'profile_image_url': '/favicon.png',
|
||||
'description': 'Submit your questions to anonymous AI chatbots and vote on the best response.',
|
||||
'model_ids': None,
|
||||
|
|
@ -2067,8 +2104,6 @@ BYPASS_ADMIN_ACCESS_CONTROL = (
|
|||
== 'true'
|
||||
)
|
||||
|
||||
ENABLE_ADMIN_CHAT_ACCESS = os.getenv('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_ADMIN_ANALYTICS = os.getenv('ENABLE_ADMIN_ANALYTICS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_COMMUNITY_SHARING = os.getenv('ENABLE_COMMUNITY_SHARING', 'True').lower() == 'true'
|
||||
|
|
@ -2079,6 +2114,7 @@ ENABLE_USER_WEBHOOKS = os.getenv('ENABLE_USER_WEBHOOKS', 'False').lower() == 'tr
|
|||
|
||||
# FastAPI / AnyIO settings
|
||||
THREAD_POOL_SIZE = os.getenv('THREAD_POOL_SIZE', None)
|
||||
THREAD_POOL_THREAD_NAME_PREFIX = os.getenv('THREAD_POOL_THREAD_NAME_PREFIX', '')
|
||||
|
||||
if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str):
|
||||
try:
|
||||
|
|
@ -2134,7 +2170,7 @@ class BannerModel(BaseModel):
|
|||
|
||||
|
||||
try:
|
||||
banners = json.loads(os.getenv('WEBUI_BANNERS', '[]'))
|
||||
banners = JSONCodec.loads(os.getenv('WEBUI_BANNERS', '[]'))
|
||||
banners = [BannerModel(**banner) for banner in banners]
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading WEBUI_BANNERS: {e}')
|
||||
|
|
@ -2157,10 +2193,20 @@ TASK_MODEL = os.getenv('TASK_MODEL', '')
|
|||
|
||||
TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '')
|
||||
|
||||
try:
|
||||
task_model_params = JSONCodec.loads(os.getenv('TASK_MODEL_PARAMS', '{}'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading TASK_MODEL_PARAMS: {e}')
|
||||
task_model_params = {}
|
||||
|
||||
TASK_MODEL_PARAMS = task_model_params
|
||||
|
||||
CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '')
|
||||
|
||||
ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true'
|
||||
|
||||
ENABLE_TOOL_PERMISSIONS = os.getenv('ENABLE_TOOL_PERMISSIONS', 'False').lower() == 'true'
|
||||
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
|
||||
|
||||
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
|
||||
|
|
@ -2470,12 +2516,12 @@ GOOGLE_OAUTH_AUTHORIZE_PARAMS = {}
|
|||
_google_oauth_authorize_params = os.getenv('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '')
|
||||
if _google_oauth_authorize_params:
|
||||
try:
|
||||
_parsed = json.loads(_google_oauth_authorize_params)
|
||||
_parsed = JSONCodec.loads(_google_oauth_authorize_params)
|
||||
if isinstance(_parsed, dict):
|
||||
GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed
|
||||
else:
|
||||
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except (JSONCodec.JSONDecodeError, TypeError):
|
||||
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
|
||||
|
||||
MICROSOFT_CLIENT_ID = os.getenv('MICROSOFT_CLIENT_ID', '')
|
||||
|
|
@ -2590,12 +2636,12 @@ OAUTH_AUTHORIZE_PARAMS = {}
|
|||
_oauth_authorize_params = os.getenv('OAUTH_AUTHORIZE_PARAMS', '')
|
||||
if _oauth_authorize_params:
|
||||
try:
|
||||
_parsed = json.loads(_oauth_authorize_params)
|
||||
_parsed = JSONCodec.loads(_oauth_authorize_params)
|
||||
if isinstance(_parsed, dict):
|
||||
OAUTH_AUTHORIZE_PARAMS = _parsed
|
||||
else:
|
||||
log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except (JSONCodec.JSONDecodeError, TypeError):
|
||||
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
|
||||
|
||||
|
||||
|
|
@ -2848,6 +2894,7 @@ DEFAULT_CONFIG = {
|
|||
'rag.external_document_loader_api_key': EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
'rag.external_document_loader_headers': EXTERNAL_DOCUMENT_LOADER_HEADERS,
|
||||
'rag.tika_server_url': TIKA_SERVER_URL,
|
||||
'rag.tika_server_version': TIKA_SERVER_VERSION,
|
||||
'rag.docling_server_url': DOCLING_SERVER_URL,
|
||||
'rag.docling_api_key': DOCLING_API_KEY,
|
||||
'rag.docling_params': DOCLING_PARAMS,
|
||||
|
|
@ -3046,6 +3093,7 @@ DEFAULT_CONFIG = {
|
|||
'ui.default_locale': DEFAULT_LOCALE,
|
||||
'ui.default_models': DEFAULT_MODELS,
|
||||
'ui.default_pinned_models': DEFAULT_PINNED_MODELS,
|
||||
'ui.default_interface_settings': DEFAULT_INTERFACE_SETTINGS,
|
||||
'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS,
|
||||
'ui.model_order_list': MODEL_ORDER_LIST,
|
||||
'models.default_metadata': DEFAULT_MODEL_METADATA,
|
||||
|
|
@ -3085,12 +3133,14 @@ DEFAULT_CONFIG = {
|
|||
'auth.admin.email': ADMIN_EMAIL,
|
||||
'task.model.default': TASK_MODEL,
|
||||
'task.model.external': TASK_MODEL_EXTERNAL,
|
||||
'task.model.params': TASK_MODEL_PARAMS,
|
||||
'chat.context_compaction.model': CONTEXT_COMPACTION_MODEL,
|
||||
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
|
||||
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
|
||||
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
|
||||
'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE,
|
||||
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
|
||||
'chat.tool_permissions.enable': ENABLE_TOOL_PERMISSIONS,
|
||||
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class ERROR_MESSAGES(str, Enum):
|
|||
|
||||
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
|
||||
|
||||
WEB_SEARCH_ERROR = lambda err='': err if err else 'Something went wrong while searching the web.'
|
||||
WEB_SEARCH_ERROR = 'Something went wrong while searching the web.'
|
||||
|
||||
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
|
||||
|
||||
|
|
@ -118,9 +118,15 @@ class ERROR_MESSAGES(str, Enum):
|
|||
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
|
||||
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
|
||||
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
|
||||
AUTOMATION_COUNT_REQUIRES_DTSTART = (
|
||||
'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window'
|
||||
)
|
||||
|
||||
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
|
||||
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
|
||||
# LICENSE covers this Open WebUI error identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
|
||||
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
|
||||
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ if FROM_INIT_PY:
|
|||
|
||||
# Check if the data directory exists in the package directory
|
||||
if DATA_DIR.exists() and DATA_DIR != NEW_DATA_DIR:
|
||||
log.info(f'Moving {DATA_DIR} to {NEW_DATA_DIR}')
|
||||
log.info('Moving %s to %s', DATA_DIR, NEW_DATA_DIR)
|
||||
for item in DATA_DIR.iterdir():
|
||||
dest = NEW_DATA_DIR / item.name
|
||||
if item.is_dir():
|
||||
|
|
@ -353,18 +353,18 @@ DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.getenv('DATABASE_SQLITE_PRAGMA_MMAP_SIZE',
|
|||
# truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default).
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.getenv('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864')
|
||||
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.getenv('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None)
|
||||
if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
|
||||
try:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
|
||||
except Exception:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
|
||||
# Seconds between presence writes per user per worker; keep under the 180s active-user window. 0 disables.
|
||||
try:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(os.getenv('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', '60'))
|
||||
except ValueError:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 60.0
|
||||
|
||||
DATABASE_ENABLE_SESSION_SHARING = os.getenv('DATABASE_ENABLE_SESSION_SHARING', 'False').lower() == 'true'
|
||||
ENABLE_PUBLIC_ACTIVE_USERS_COUNT = os.getenv('ENABLE_PUBLIC_ACTIVE_USERS_COUNT', 'True').lower() == 'true'
|
||||
RESET_CONFIG_ON_START = os.getenv('RESET_CONFIG_ON_START', 'False').lower() == 'true'
|
||||
ENABLE_REALTIME_CHAT_SAVE = os.getenv('ENABLE_REALTIME_CHAT_SAVE', 'False').lower() == 'true'
|
||||
ENABLE_QUERIES_CACHE = os.getenv('ENABLE_QUERIES_CACHE', 'False').lower() == 'true'
|
||||
ENABLE_ADMIN_CHAT_ACCESS = os.getenv('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true'
|
||||
RAG_SYSTEM_CONTEXT = os.getenv('RAG_SYSTEM_CONTEXT', 'False').lower() == 'true'
|
||||
|
||||
####################################
|
||||
|
|
@ -376,6 +376,11 @@ REDIS_CLUSTER = os.getenv('REDIS_CLUSTER', 'False').lower() == 'true'
|
|||
|
||||
REDIS_KEY_PREFIX = os.getenv('REDIS_KEY_PREFIX', 'open-webui')
|
||||
|
||||
try:
|
||||
REDIS_RESPONSE_STREAM_TTL = int(os.getenv('REDIS_RESPONSE_STREAM_TTL', '3600'))
|
||||
except ValueError:
|
||||
REDIS_RESPONSE_STREAM_TTL = 3600
|
||||
|
||||
REDIS_SENTINEL_HOSTS = os.getenv('REDIS_SENTINEL_HOSTS', '')
|
||||
REDIS_SENTINEL_PORT = os.getenv('REDIS_SENTINEL_PORT', '26379')
|
||||
|
||||
|
|
@ -443,6 +448,9 @@ try:
|
|||
except (ValueError, TypeError):
|
||||
UVICORN_WORKERS = 1
|
||||
|
||||
# tiny delta-stream frames make per-frame websocket compression CPU-bound, allow opting out (true/false)
|
||||
UVICORN_WS_PER_MESSAGE_DEFLATE = os.getenv('UVICORN_WS_PER_MESSAGE_DEFLATE', 'True').lower() == 'true'
|
||||
|
||||
####################################
|
||||
# WEBSOCKET SUPPORT
|
||||
####################################
|
||||
|
|
@ -499,6 +507,15 @@ try:
|
|||
except ValueError:
|
||||
WEBSOCKET_SERVER_PING_INTERVAL = 25
|
||||
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL = os.getenv('WEBSOCKET_HEARTBEAT_INTERVAL', '')
|
||||
if WEBSOCKET_HEARTBEAT_INTERVAL == '':
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL = None
|
||||
else:
|
||||
try:
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL = min(max(int(WEBSOCKET_HEARTBEAT_INTERVAL), 5), 90)
|
||||
except ValueError:
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL = 30
|
||||
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT = os.getenv('WEBSOCKET_EVENT_CALLER_TIMEOUT', '')
|
||||
|
||||
if WEBSOCKET_EVENT_CALLER_TIMEOUT == '':
|
||||
|
|
@ -571,6 +588,8 @@ def _parse_ssl_env(value: str) -> 'bool | _ssl.SSLContext':
|
|||
|
||||
REQUESTS_VERIFY = os.getenv('REQUESTS_VERIFY', 'True').lower() == 'true'
|
||||
|
||||
TAVILY_API_BASE_URL = os.getenv('TAVILY_API_BASE_URL', 'https://api.tavily.com').rstrip('/')
|
||||
|
||||
_aiohttp_timeout_raw = os.getenv('AIOHTTP_CLIENT_TIMEOUT', '')
|
||||
try:
|
||||
AIOHTTP_CLIENT_TIMEOUT = int(_aiohttp_timeout_raw) if _aiohttp_timeout_raw else None
|
||||
|
|
@ -602,6 +621,18 @@ SEARXNG_CLIENT_KEY_FILE = os.getenv('SEARXNG_CLIENT_KEY_FILE', '').strip()
|
|||
# When False (default), outbound HTTP requests do not follow 3xx redirects.
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.getenv('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true'
|
||||
|
||||
# Opt-in c-ares DNS resolution (aiodns). Off by default: c-ares breaks name
|
||||
# resolution in some environments (#28013, #28215). Must run before any
|
||||
# TCPConnector is constructed.
|
||||
AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER = os.getenv('AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER', 'False').lower() == 'true'
|
||||
|
||||
if not AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER:
|
||||
import aiohttp
|
||||
|
||||
aiohttp.DefaultResolver = aiohttp.resolver.ThreadedResolver # for plugin code
|
||||
aiohttp.resolver.DefaultResolver = aiohttp.resolver.ThreadedResolver
|
||||
aiohttp.connector.DefaultResolver = aiohttp.resolver.ThreadedResolver
|
||||
|
||||
# Optional User-Agent override for outbound web-loader fetches. When set,
|
||||
# SafeWebBaseLoader sends this value instead of the default python-requests UA
|
||||
# which is aggressively blocked by Cloudflare, Wikipedia, and similar services.
|
||||
|
|
@ -791,6 +822,16 @@ BYPASS_RETRIEVAL_ACCESS_CONTROL = os.getenv('BYPASS_RETRIEVAL_ACCESS_CONTROL', '
|
|||
# for non-admin users. When False (default), unknown collection names are
|
||||
# denied — closing the legacy unscoped namespace.
|
||||
ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS = os.getenv('ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS', 'False').lower() == 'true'
|
||||
|
||||
# Falls back to the upload size limit, because a document cannot legitimately carry more metadata
|
||||
# than the file itself is allowed to be. Left unbounded, a small archive that expands enormously
|
||||
# during extraction can exhaust memory. RAG_FILE_MAX_SIZE is in MB.
|
||||
RAG_METADATA_MAX_VALUE_CHARS = (
|
||||
int(os.getenv('RAG_METADATA_MAX_VALUE_CHARS'))
|
||||
if os.getenv('RAG_METADATA_MAX_VALUE_CHARS')
|
||||
else ((int(os.getenv('RAG_FILE_MAX_SIZE', '0')) or 0) * 1024 * 1024 or None)
|
||||
)
|
||||
|
||||
MINERU_MAX_MARKDOWN_BYTES = (
|
||||
int(os.getenv('MINERU_MAX_MARKDOWN_BYTES')) if os.getenv('MINERU_MAX_MARKDOWN_BYTES') else None
|
||||
)
|
||||
|
|
@ -888,10 +929,18 @@ if LICENSE_PUBLIC_KEY:
|
|||
# WEBUI Identity
|
||||
####################################
|
||||
|
||||
# LICENSE covers this Open WebUI branding surface, including name, logo,
|
||||
# visual, textual, symbolic identifiers, metadata, and surrounding UI.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
WEBUI_NAME = os.getenv('WEBUI_NAME', 'Open WebUI')
|
||||
if WEBUI_NAME != 'Open WebUI':
|
||||
WEBUI_NAME += ' (Open WebUI)'
|
||||
|
||||
# LICENSE covers this Open WebUI branding surface, including this favicon
|
||||
# and any visual, textual, or symbolic identifiers it preserves.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png'
|
||||
WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build')
|
||||
TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '')
|
||||
|
|
@ -948,6 +997,10 @@ except ValueError:
|
|||
# Progressive Web App
|
||||
####################################
|
||||
|
||||
# LICENSE covers this install-time Open WebUI branding surface, including
|
||||
# names, logos, manifests, metadata, and surrounding UI.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
EXTERNAL_PWA_MANIFEST_URL = os.getenv('EXTERNAL_PWA_MANIFEST_URL', None)
|
||||
|
||||
####################################
|
||||
|
|
|
|||
|
|
@ -411,6 +411,11 @@ class EventDefinitions(BaseModel):
|
|||
description='Retrieval content was processed.',
|
||||
message='Retrieval Content processed',
|
||||
)
|
||||
RETRIEVAL_CONTENT_PROCESS_FAILED: EventDefinition = EventDefinition(
|
||||
name='retrieval.content.process_failed',
|
||||
description='Retrieval content processing failed.',
|
||||
message='Retrieval Content process failed',
|
||||
)
|
||||
RETRIEVAL_COLLECTION_DELETED: EventDefinition = EventDefinition(
|
||||
name='retrieval.collection.deleted',
|
||||
description='A retrieval collection was deleted.',
|
||||
|
|
@ -666,6 +671,7 @@ NOTIFICATION_EVENTS = (
|
|||
EVENTS.CHAT_FAILED.name,
|
||||
EVENTS.CHANNEL_MESSAGE.name,
|
||||
EVENTS.CALENDAR_ALERT.name,
|
||||
EVENTS.RETRIEVAL_CONTENT_PROCESS_FAILED.name,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1026,6 +1032,9 @@ def build_event(
|
|||
|
||||
|
||||
async def dispatch_webhook_event(app: Any, event: Event) -> None:
|
||||
# LICENSE covers this Open WebUI webhook identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI')
|
||||
subject = event.subject or {}
|
||||
subject_id = subject.get('id')
|
||||
|
|
@ -1077,6 +1086,20 @@ class NotificationEventSink:
|
|||
schedule_notification_dispatch(app, event)
|
||||
|
||||
|
||||
class SocketSessionEventSink:
|
||||
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
|
||||
if event.event not in {EVENTS.USER_DELETED.name, EVENTS.USER_ROLE_UPDATED.name}:
|
||||
return
|
||||
|
||||
subject = event.subject or {}
|
||||
if subject.get('type') != 'user' or not subject.get('id'):
|
||||
return
|
||||
|
||||
from open_webui.socket.main import disconnect_user_sessions
|
||||
|
||||
await disconnect_user_sessions(str(subject['id']))
|
||||
|
||||
|
||||
async def dispatch_event_functions(
|
||||
app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None
|
||||
) -> None:
|
||||
|
|
@ -1145,7 +1168,7 @@ class EventFunctionSink:
|
|||
schedule_event_function_dispatch(app, event, request)
|
||||
|
||||
|
||||
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
|
||||
EVENT_SINKS = [SocketSessionEventSink(), EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
|
||||
|
||||
|
||||
async def publish_event(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import AsyncGenerator, Generator, Iterator
|
||||
|
|
@ -29,6 +28,7 @@ from open_webui.socket.main import (
|
|||
get_event_emitter,
|
||||
)
|
||||
from open_webui.utils.access_control import check_model_access
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.misc import (
|
||||
add_or_update_system_message,
|
||||
get_last_user_message,
|
||||
|
|
@ -100,7 +100,7 @@ async def get_function_models(request):
|
|||
log.exception(e)
|
||||
sub_pipes = []
|
||||
|
||||
log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}")
|
||||
log.debug("get_function_models: function '%s' is a manifold of %s", pipe.id, sub_pipes)
|
||||
|
||||
for p in sub_pipes:
|
||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||
|
|
@ -126,7 +126,10 @@ async def get_function_models(request):
|
|||
pipe_flag = {'type': 'pipe'}
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||
"get_function_models: function '%s' is a single pipe { 'id': %s, 'name': %s }",
|
||||
pipe.id,
|
||||
pipe.id,
|
||||
pipe.name,
|
||||
)
|
||||
|
||||
pipe_models.append(
|
||||
|
|
@ -170,7 +173,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
line = line.model_dump_json()
|
||||
line = f'data: {line}'
|
||||
if isinstance(line, dict):
|
||||
line = f'data: {json.dumps(line)}'
|
||||
line = f'data: {JSONCodec.dumps(line)}'
|
||||
|
||||
try:
|
||||
line = line.decode('utf-8')
|
||||
|
|
@ -181,7 +184,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
return f'{line}\n\n'
|
||||
else:
|
||||
line = openai_chat_chunk_message_template(form_data['model'], line)
|
||||
return f'data: {json.dumps(line)}\n\n'
|
||||
return f'data: {JSONCodec.dumps(line)}\n\n'
|
||||
|
||||
def get_pipe_id(form_data: dict) -> str:
|
||||
pipe_id = form_data['model']
|
||||
|
|
@ -209,6 +212,9 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
|
||||
return params
|
||||
|
||||
# Set server-side by utils/chat.py, never by client input. Mirrors the routers.
|
||||
bypass_system_prompt = getattr(request.state, 'bypass_system_prompt', False)
|
||||
|
||||
# Copy so the base-model substitution below doesn't leak into the caller's
|
||||
# payload, which the tool-call continuation re-submits. Mirrors the routers.
|
||||
form_data = {**form_data}
|
||||
|
|
@ -288,7 +294,8 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
if params:
|
||||
system = params.pop('system', None)
|
||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
||||
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
if not bypass_system_prompt:
|
||||
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
|
||||
pipe_id = get_pipe_id(form_data)
|
||||
function_module = await get_function_module_by_id(request, pipe_id)
|
||||
|
|
@ -308,17 +315,17 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
yield data
|
||||
return
|
||||
if isinstance(res, dict):
|
||||
yield f'data: {json.dumps(res)}\n\n'
|
||||
yield f'data: {JSONCodec.dumps(res)}\n\n'
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'Error: {e}')
|
||||
yield f'data: {json.dumps({"error": {"detail": str(e)}})}\n\n'
|
||||
yield f'data: {JSONCodec.dumps({"error": {"detail": str(e)}})}\n\n'
|
||||
return
|
||||
|
||||
if isinstance(res, str):
|
||||
message = openai_chat_chunk_message_template(form_data['model'], res)
|
||||
yield f'data: {json.dumps(message)}\n\n'
|
||||
yield f'data: {JSONCodec.dumps(message)}\n\n'
|
||||
|
||||
if isinstance(res, Iterator):
|
||||
for line in res:
|
||||
|
|
@ -330,7 +337,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
|
||||
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
|
||||
finish_message['choices'][0]['finish_reason'] = 'stop'
|
||||
yield f'data: {json.dumps(finish_message)}\n\n'
|
||||
yield f'data: {JSONCodec.dumps(finish_message)}\n\n'
|
||||
yield 'data: [DONE]'
|
||||
|
||||
return StreamingResponse(stream_content(), media_type='text/event-stream')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -28,6 +27,7 @@ from open_webui.env import (
|
|||
ENABLE_DB_MIGRATIONS,
|
||||
OPEN_WEBUI_DIR,
|
||||
)
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from sqlalchemy import Dialect, MetaData, create_engine, event, types
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
|
@ -124,18 +124,18 @@ class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
|
|||
"""Store arbitrary Python objects as JSON-encoded TEXT.
|
||||
|
||||
Used instead of native JSON columns for portability across SQLite and
|
||||
PostgreSQL. Values are serialized with ``json.dumps`` on write and
|
||||
deserialized with ``json.loads`` on read.
|
||||
PostgreSQL. Values are serialized with ``JSONCodec.dumps`` on write and
|
||||
deserialized with ``JSONCodec.loads`` on read.
|
||||
"""
|
||||
|
||||
impl = types.UnicodeText
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return json.dumps(value) if value is not None else None
|
||||
return JSONCodec.dumps(value) if value is not None else None
|
||||
|
||||
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return json.loads(value) if value is not None else None
|
||||
return JSONCodec.loads(value) if value is not None else None
|
||||
|
||||
def copy(self, **kwargs: Any) -> Self:
|
||||
return JSONField(length=self.impl.length)
|
||||
|
|
@ -202,6 +202,20 @@ def enable_iam_token_auth(connectable) -> None:
|
|||
return
|
||||
|
||||
engine = getattr(connectable, 'sync_engine', connectable)
|
||||
url = engine.url
|
||||
auth = _rds_iam_token_auth
|
||||
# The token is bound to one host/port/user pair; leave other databases on their own credentials.
|
||||
if (url.host, url.port or 5432, url.username) != (auth.host, auth.port, auth.username):
|
||||
log.warning(
|
||||
'AWS RDS IAM token auth not applied to %s: the token is issued for %s@%s:%s, '
|
||||
'so this connection uses the password from its own URL',
|
||||
url.render_as_string(hide_password=True),
|
||||
auth.username,
|
||||
auth.host,
|
||||
auth.port,
|
||||
)
|
||||
return
|
||||
|
||||
if not event.contains(engine, 'do_connect', _set_iam_token_password):
|
||||
event.listen(engine, 'do_connect', _set_iam_token_password)
|
||||
|
||||
|
|
@ -232,6 +246,27 @@ def _make_async_url(url: str) -> str:
|
|||
return url
|
||||
|
||||
|
||||
def _json_codec_kwargs(kwargs: dict) -> dict:
|
||||
"""Default an engine to JSONCodec for native ``JSON`` columns.
|
||||
|
||||
Unlike ``JSONField``, those serialize through the engine, which otherwise uses
|
||||
stdlib ``json``. With ``ENABLE_ORJSON`` off JSONCodec is stdlib ``json`` anyway.
|
||||
"""
|
||||
kwargs.setdefault('json_serializer', JSONCodec.dumps)
|
||||
kwargs.setdefault('json_deserializer', JSONCodec.loads)
|
||||
return kwargs
|
||||
|
||||
|
||||
def _create_engine(*args, **kwargs):
|
||||
"""``create_engine`` with the app JSON codec wired in."""
|
||||
return create_engine(*args, **_json_codec_kwargs(kwargs))
|
||||
|
||||
|
||||
def _create_async_engine(*args, **kwargs):
|
||||
"""``create_async_engine`` with the app JSON codec wired in."""
|
||||
return create_async_engine(*args, **_json_codec_kwargs(kwargs))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SYNC ENGINE (used only for: startup migrations, config loading,
|
||||
# Alembic, peewee migration, health checks)
|
||||
|
|
@ -260,7 +295,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
|||
# in the native sqlcipher3 C library. Use NullPool by default for safety,
|
||||
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
|
||||
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
|
||||
engine = create_engine(
|
||||
engine = _create_engine(
|
||||
'sqlite://',
|
||||
creator=create_sqlcipher_connection,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
|
|
@ -272,7 +307,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
|||
echo=False,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
engine = _create_engine(
|
||||
'sqlite://',
|
||||
creator=create_sqlcipher_connection,
|
||||
poolclass=NullPool,
|
||||
|
|
@ -282,7 +317,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
|||
log.info('Connected to encrypted SQLite database using SQLCipher')
|
||||
|
||||
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
|
||||
engine = _create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
|
||||
|
||||
def _apply_sqlite_pragmas(dbapi_connection):
|
||||
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
|
||||
|
|
@ -314,7 +349,7 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
|||
else:
|
||||
if isinstance(DATABASE_POOL_SIZE, int):
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
engine = create_engine(
|
||||
engine = _create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||
|
|
@ -324,9 +359,9 @@ else:
|
|||
poolclass=QueuePool,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
|
||||
engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
|
||||
else:
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||
engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
enable_iam_token_auth(engine)
|
||||
|
||||
|
|
@ -373,7 +408,7 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
|||
# No pool_pre_ping: a local SQLite file cannot drop connections, and the
|
||||
# ping costs a worker-thread hop plus a SELECT 1 on every checkout.
|
||||
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
|
||||
async_engine = create_async_engine(
|
||||
async_engine = _create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={'check_same_thread': False},
|
||||
pool_size=_sqlite_pool_size,
|
||||
|
|
@ -387,7 +422,7 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
|||
else:
|
||||
if isinstance(DATABASE_POOL_SIZE, int):
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
async_engine = create_async_engine(
|
||||
async_engine = _create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||
|
|
@ -396,13 +431,13 @@ else:
|
|||
pool_pre_ping=True,
|
||||
)
|
||||
else:
|
||||
async_engine = create_async_engine(
|
||||
async_engine = _create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
else:
|
||||
async_engine = create_async_engine(
|
||||
async_engine = _create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import copy
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
import aiohttp
|
||||
import anyio.to_thread
|
||||
from cryptography.fernet import InvalidToken
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
|
|
@ -64,6 +66,7 @@ from open_webui.config import (
|
|||
ONEDRIVE_SHAREPOINT_URL,
|
||||
STATIC_DIR,
|
||||
THREAD_POOL_SIZE,
|
||||
THREAD_POOL_THREAD_NAME_PREFIX,
|
||||
WEBUI_AUTH,
|
||||
WEBUI_NAME,
|
||||
async_reset_config,
|
||||
|
|
@ -83,19 +86,19 @@ from open_webui.env import (
|
|||
ENABLE_COMPRESSION_MIDDLEWARE,
|
||||
ENABLE_CUSTOM_MODEL_FALLBACK,
|
||||
ENABLE_EASTER_EGGS,
|
||||
ENABLE_PLUGINS,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
# OAuth Back-Channel Logout
|
||||
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
|
||||
ENABLE_OTEL,
|
||||
ENABLE_PLUGINS,
|
||||
ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
ENABLE_PYODIDE_FILE_PERSISTENCE,
|
||||
# SCIM
|
||||
ENABLE_SCIM,
|
||||
ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
|
||||
ENABLE_STAR_SESSIONS_MIDDLEWARE,
|
||||
ENABLE_PYODIDE_FILE_PERSISTENCE,
|
||||
ENABLE_VERSION_UPDATE_CHECK,
|
||||
ENABLE_WEBSOCKET_SUPPORT,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
GLOBAL_LOG_LEVEL,
|
||||
INSTANCE_ID,
|
||||
LICENSE_KEY,
|
||||
|
|
@ -108,6 +111,7 @@ from open_webui.env import (
|
|||
SAFE_MODE,
|
||||
SCIM_TOKEN,
|
||||
VERSION,
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL,
|
||||
# Admin Account Runtime Creation
|
||||
WEBUI_ADMIN_EMAIL,
|
||||
WEBUI_ADMIN_NAME,
|
||||
|
|
@ -121,12 +125,14 @@ from open_webui.env import (
|
|||
from open_webui.events import (
|
||||
EVENTS,
|
||||
delete_event_webhook,
|
||||
get_event_catalog as get_event_catalog_items,
|
||||
get_event_webhooks,
|
||||
migrate_legacy_webhook_config,
|
||||
publish_event,
|
||||
upsert_event_webhook,
|
||||
)
|
||||
from open_webui.events import (
|
||||
get_event_catalog as get_event_catalog_items,
|
||||
)
|
||||
from open_webui.internal.db import engine, get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
|
|
@ -134,7 +140,7 @@ from open_webui.models.chats import ChatForm, Chats
|
|||
from open_webui.models.config import Config
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.messages import Messages
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.models import Models, normalize_model_tags
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.routers import (
|
||||
analytics,
|
||||
|
|
@ -154,8 +160,8 @@ from open_webui.routers import (
|
|||
knowledge,
|
||||
memories,
|
||||
models,
|
||||
notifications,
|
||||
notes,
|
||||
notifications,
|
||||
ollama,
|
||||
openai,
|
||||
pipelines,
|
||||
|
|
@ -198,13 +204,9 @@ from open_webui.tasks import (
|
|||
) # Import from tasks.py
|
||||
from open_webui.utils import logger
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_write_access
|
||||
from open_webui.utils.actions import chat_action as chat_action_handler
|
||||
from open_webui.utils.asgi_middleware import (
|
||||
AuthTokenMiddleware,
|
||||
CommitSessionMiddleware,
|
||||
RedirectMiddleware,
|
||||
WebsocketUpgradeGuardMiddleware,
|
||||
)
|
||||
from open_webui.utils.asgi_middleware import AppHTTPMiddleware
|
||||
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
|
||||
from open_webui.utils.auth import (
|
||||
create_admin_user,
|
||||
|
|
@ -229,14 +231,17 @@ from open_webui.utils.chat_variables import (
|
|||
normalize_chat_variables,
|
||||
)
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.json_response import apply_orjson_http_json
|
||||
from open_webui.utils.logger import start_logger
|
||||
from open_webui.utils.middleware import (
|
||||
background_tasks_handler,
|
||||
build_chat_response_context,
|
||||
drain_approved_tool_calls,
|
||||
process_chat_payload,
|
||||
process_chat_response,
|
||||
)
|
||||
from open_webui.utils.misc import get_response_error_detail, merge_model_params
|
||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||
from open_webui.utils.models import (
|
||||
check_model_access,
|
||||
|
|
@ -258,8 +263,12 @@ from open_webui.utils.oauth import (
|
|||
)
|
||||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.redis import get_redis_client
|
||||
from open_webui.utils.security_headers import SecurityHeadersMiddleware
|
||||
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
|
||||
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
|
||||
from open_webui.utils.tool_approval import (
|
||||
ResolveToolCallForm,
|
||||
build_tool_approval_resume_payload,
|
||||
resolve_tool_call_output,
|
||||
)
|
||||
from open_webui.utils.tools import set_terminal_servers, set_tool_servers
|
||||
|
||||
if SAFE_MODE:
|
||||
|
|
@ -320,6 +329,9 @@ https://github.com/open-webui/open-webui
|
|||
print(banner)
|
||||
except UnicodeEncodeError:
|
||||
# Stdout can't encode the box-drawing banner (Windows cp1252, redirected/headless stdout); fall back to ASCII.
|
||||
# LICENSE covers this Open WebUI CLI identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
print(f'Open WebUI v{VERSION} - building the best AI user interface.\nhttps://github.com/open-webui/open-webui')
|
||||
|
||||
|
||||
|
|
@ -329,6 +341,16 @@ async def lifespan(app: FastAPI):
|
|||
# This allows sync functions to schedule work on the main loop without blocking health checks
|
||||
app.state.main_loop = asyncio.get_running_loop()
|
||||
|
||||
if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0:
|
||||
# asyncio offloads bypass AnyIO's limiter, so configure both before the first offload.
|
||||
anyio.to_thread.current_default_thread_limiter().total_tokens = THREAD_POOL_SIZE
|
||||
app.state.main_loop.set_default_executor(
|
||||
ThreadPoolExecutor(
|
||||
max_workers=THREAD_POOL_SIZE,
|
||||
thread_name_prefix=THREAD_POOL_THREAD_NAME_PREFIX,
|
||||
)
|
||||
)
|
||||
|
||||
app.state.instance_id = INSTANCE_ID
|
||||
start_logger()
|
||||
|
||||
|
|
@ -364,16 +386,12 @@ async def lifespan(app: FastAPI):
|
|||
if app.state.redis is not None:
|
||||
app.state.redis_task_command_listener = asyncio.create_task(redis_task_command_listener(app))
|
||||
|
||||
if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0:
|
||||
limiter = anyio.to_thread.current_default_thread_limiter()
|
||||
limiter.total_tokens = THREAD_POOL_SIZE
|
||||
|
||||
asyncio.create_task(periodic_usage_pool_cleanup())
|
||||
asyncio.create_task(periodic_session_pool_cleanup())
|
||||
app.state.periodic_usage_pool_cleanup = asyncio.create_task(periodic_usage_pool_cleanup())
|
||||
app.state.periodic_session_pool_cleanup = asyncio.create_task(periodic_session_pool_cleanup())
|
||||
|
||||
from open_webui.utils.automations import scheduler_worker_loop
|
||||
|
||||
asyncio.create_task(scheduler_worker_loop(app))
|
||||
app.state.scheduler_worker_loop = asyncio.create_task(scheduler_worker_loop(app))
|
||||
|
||||
if await Config.get('models.base_models_cache'):
|
||||
try:
|
||||
|
|
@ -420,13 +438,13 @@ async def lifespan(app: FastAPI):
|
|||
log.info('Initializing tool servers...')
|
||||
try:
|
||||
await set_tool_servers(mock_request)
|
||||
log.info(f'Initialized {len(app.state.TOOL_SERVERS)} tool server(s)')
|
||||
log.info('Initialized %s tool server(s)', len(app.state.TOOL_SERVERS))
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to initialize tool servers at startup: {e}')
|
||||
|
||||
try:
|
||||
await set_terminal_servers(mock_request)
|
||||
log.info(f'Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)')
|
||||
log.info('Initialized %s terminal server(s)', len(app.state.TERMINAL_SERVERS))
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to initialize terminal servers at startup: {e}')
|
||||
|
||||
|
|
@ -454,6 +472,10 @@ async def lifespan(app: FastAPI):
|
|||
if hasattr(app.state, 'redis_task_command_listener'):
|
||||
app.state.redis_task_command_listener.cancel()
|
||||
|
||||
app.state.periodic_usage_pool_cleanup.cancel()
|
||||
app.state.periodic_session_pool_cleanup.cancel()
|
||||
app.state.scheduler_worker_loop.cancel()
|
||||
|
||||
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
|
||||
|
||||
|
||||
|
|
@ -461,6 +483,9 @@ async def lifespan(app: FastAPI):
|
|||
# response_model routes keep FastAPI's Pydantic fast path either way.
|
||||
apply_orjson_http_json()
|
||||
|
||||
# LICENSE covers this Open WebUI API metadata identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
app = FastAPI(
|
||||
title='Open WebUI',
|
||||
docs_url='/docs' if ENV == 'dev' else None,
|
||||
|
|
@ -483,6 +508,10 @@ app.state.oauth_client_manager = oauth_client_manager
|
|||
app.state.instance_id = None
|
||||
app.state.redis = None
|
||||
|
||||
# LICENSE covers this Open WebUI branding surface, including name, logo,
|
||||
# visual, textual, symbolic identifiers, metadata, and surrounding UI.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
app.state.WEBUI_NAME = WEBUI_NAME
|
||||
app.state.LICENSE_METADATA = None
|
||||
app.state.USER_COUNT = None
|
||||
|
|
@ -592,8 +621,18 @@ async def initialize_runtime_config(app: FastAPI):
|
|||
f'mcp:{server_id}',
|
||||
OAuthClientInformationFull(**oauth_client_info),
|
||||
)
|
||||
except InvalidToken:
|
||||
log.error(
|
||||
'Error adding OAuth client for MCP tool server %s: InvalidToken. '
|
||||
'Stored OAuth client data is invalid; reconnect this tool server.',
|
||||
server_id,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f'Error adding OAuth client for MCP tool server {server_id}: {e}')
|
||||
log.error(
|
||||
'Error adding OAuth client for MCP tool server %s: %s',
|
||||
server_id,
|
||||
f'{type(e).__name__}: {e}' if str(e) else type(e).__name__,
|
||||
)
|
||||
|
||||
arena_models = await Config.get('evaluation.arena.models', []) or []
|
||||
if any('access_control' in m.get('meta', {}) for m in arena_models):
|
||||
|
|
@ -763,11 +802,7 @@ if ENABLE_COMPRESSION_MIDDLEWARE:
|
|||
# `terminate_force_close` tracebacks under aiosqlite and as random
|
||||
# CancelledError storms across the request path. See
|
||||
# `open_webui.utils.asgi_middleware` for the rationale.
|
||||
app.add_middleware(RedirectMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(CommitSessionMiddleware)
|
||||
app.add_middleware(AuthTokenMiddleware, fastapi_app=app)
|
||||
app.add_middleware(WebsocketUpgradeGuardMiddleware)
|
||||
app.add_middleware(AppHTTPMiddleware)
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
|
|
@ -855,19 +890,17 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
|||
models = await get_filtered_models(models, user)
|
||||
|
||||
for model in models:
|
||||
info = model.get('info') if isinstance(model.get('info'), dict) else {}
|
||||
meta = info.get('meta') if isinstance(info.get('meta'), dict) else {}
|
||||
|
||||
# Remove profile image URL to reduce payload size
|
||||
if model.get('info', {}).get('meta', {}).get('profile_image_url'):
|
||||
model['info']['meta'].pop('profile_image_url', None)
|
||||
meta.pop('profile_image_url', None)
|
||||
|
||||
try:
|
||||
model_tags = [tag.get('name') for tag in model.get('info', {}).get('meta', {}).get('tags', [])]
|
||||
tags = [tag.get('name') for tag in model.get('tags', [])]
|
||||
if 'tags' in meta:
|
||||
meta['tags'] = normalize_model_tags(meta['tags'])
|
||||
|
||||
tags = list(set(model_tags + tags))
|
||||
model['tags'] = [{'name': tag} for tag in tags]
|
||||
except Exception as e:
|
||||
log.debug(f'Error processing model tags: {e}')
|
||||
model['tags'] = []
|
||||
tags = normalize_model_tags(meta.get('tags')) + normalize_model_tags(model.get('tags'))
|
||||
model['tags'] = list({tag['name']: tag for tag in tags}.values())
|
||||
|
||||
model_order_list = await Config.get('ui.model_order_list')
|
||||
if model_order_list:
|
||||
|
|
@ -882,7 +915,7 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
|||
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug(
|
||||
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
|
||||
f'/api/models returned filtered models accessible to the user: {JSONCodec.dumps([model.get("id") for model in models])}'
|
||||
)
|
||||
return {'data': models}
|
||||
|
||||
|
|
@ -935,7 +968,7 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend
|
|||
prefix_id = api_config.get('prefix_id', None)
|
||||
actual_model = strip_provider_model_prefix(model_id, prefix_id)
|
||||
|
||||
payload = json.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''})
|
||||
payload = JSONCodec.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''})
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
|
|
@ -1066,53 +1099,67 @@ async def chat_completion(
|
|||
metadata = {}
|
||||
try:
|
||||
model_info = None
|
||||
fallback_model = None
|
||||
missing_base_model = False
|
||||
if not model_item.get('direct', False):
|
||||
if model_id not in request.app.state.MODELS:
|
||||
raise Exception('Model not found')
|
||||
|
||||
model = request.app.state.MODELS[model_id]
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
missing_base_model = bool(
|
||||
model_info and model_info.base_model_id and model_info.base_model_id not in request.app.state.MODELS
|
||||
)
|
||||
|
||||
if missing_base_model and ENABLE_CUSTOM_MODEL_FALLBACK:
|
||||
fallback_model_id = next(
|
||||
(
|
||||
model_id.strip()
|
||||
for model_id in ((await Config.get('ui.default_models')) or '').split(',')
|
||||
if model_id.strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fallback_model_id:
|
||||
fallback_model = request.app.state.MODELS.get(fallback_model_id)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
try:
|
||||
await check_model_access(user, model, model_info=model_info)
|
||||
access_model_info = (
|
||||
model_info.model_copy(update={'base_model_id': None})
|
||||
if fallback_model is not None
|
||||
else model_info
|
||||
)
|
||||
await check_model_access(user, model, model_info=access_model_info)
|
||||
if fallback_model is not None:
|
||||
await check_model_access(user, fallback_model)
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
model = model_item
|
||||
await _set_direct_model(request, model, user)
|
||||
|
||||
# Read before the fallback below can rebind model to a different one.
|
||||
model_capabilities = ((model.get('info') or {}).get('meta') or {}).get('capabilities') or {}
|
||||
|
||||
# Model params: global defaults as base, per-model overrides win
|
||||
default_model_params = await Config.get('models.default_params', {}) or {}
|
||||
model_info_params = {
|
||||
**default_model_params,
|
||||
**(model_info.params.model_dump() if model_info and model_info.params else {}),
|
||||
}
|
||||
default_model_params = copy.deepcopy(await Config.get('models.default_params', {}) or {})
|
||||
model_info_params = merge_model_params(
|
||||
default_model_params,
|
||||
model_info.params.model_dump() if model_info and model_info.params else {},
|
||||
)
|
||||
request_params = {key: value for key, value in (form_data.get('params') or {}).items() if value is not None}
|
||||
if model_info_params or request_params:
|
||||
form_data['params'] = {
|
||||
**model_info_params,
|
||||
**request_params,
|
||||
}
|
||||
form_data['params'] = merge_model_params(model_info_params, request_params)
|
||||
|
||||
# Check base model existence for custom models
|
||||
if model_info and model_info.base_model_id:
|
||||
base_model_id = model_info.base_model_id
|
||||
if base_model_id not in request.app.state.MODELS:
|
||||
if ENABLE_CUSTOM_MODEL_FALLBACK:
|
||||
default_models = ((await Config.get('ui.default_models')) or '').split(',')
|
||||
|
||||
fallback_model_id = default_models[0].strip() if default_models[0] else None
|
||||
|
||||
if fallback_model_id and fallback_model_id in request.app.state.MODELS:
|
||||
# Update model and form_data so routing uses the fallback model's type
|
||||
model = request.app.state.MODELS[fallback_model_id]
|
||||
form_data['model'] = fallback_model_id
|
||||
else:
|
||||
raise Exception('Model not found')
|
||||
else:
|
||||
raise Exception('Model not found')
|
||||
if missing_base_model:
|
||||
if fallback_model is None:
|
||||
raise Exception('Model not found')
|
||||
# Update model and form_data so routing uses the fallback model's type
|
||||
model = fallback_model
|
||||
form_data['model'] = fallback_model['id']
|
||||
|
||||
# Chat Params
|
||||
stream_delta_chunk_size = form_data.get('params', {}).get('stream_delta_chunk_size')
|
||||
|
|
@ -1123,6 +1170,10 @@ async def chat_completion(
|
|||
if model_info_params.get('stream_response') is not None:
|
||||
form_data['stream'] = model_info_params.get('stream_response')
|
||||
|
||||
# Providers only report token counts when asked, so ask on every caller's behalf.
|
||||
if form_data.get('stream') and model_capabilities.get('usage'):
|
||||
form_data['stream_options'] = {**(form_data.get('stream_options') or {}), 'include_usage': True}
|
||||
|
||||
if model_info_params.get('stream_delta_chunk_size'):
|
||||
stream_delta_chunk_size = model_info_params.get('stream_delta_chunk_size')
|
||||
|
||||
|
|
@ -1155,7 +1206,7 @@ async def chat_completion(
|
|||
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
|
||||
|
||||
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
|
||||
chat_id = form_data.get('chat_id') or ''
|
||||
chat_id = form_data.pop('chat_id', None) or ''
|
||||
chat_variables = form_data.pop('chat_variables', None)
|
||||
if chat_variables is None:
|
||||
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
|
||||
|
|
@ -1177,15 +1228,28 @@ async def chat_completion(
|
|||
):
|
||||
tool_servers = None
|
||||
|
||||
automation_id = form_data.pop('automation_id', None)
|
||||
tool_approval_mode = (
|
||||
'full'
|
||||
if automation_id or chat_id.startswith('channel:')
|
||||
else (
|
||||
form_data.get('params', {}).get('tool_approval_mode')
|
||||
if await Config.get('chat.tool_permissions.enable', False)
|
||||
else 'full'
|
||||
)
|
||||
or 'full'
|
||||
)
|
||||
|
||||
metadata = {
|
||||
'user_id': user.id,
|
||||
'user_agent': request.headers.get('user-agent', '') or '',
|
||||
'internal': getattr(request.state, 'internal', False) is True,
|
||||
'chat_id': form_data.pop('chat_id', None) or '',
|
||||
'chat_id': chat_id,
|
||||
'user_message': user_message,
|
||||
'user_message_id': user_message.get('id') if user_message else None,
|
||||
'assistant_message_id': form_data.pop('assistant_message_id', None),
|
||||
'session_id': form_data.pop('session_id', None),
|
||||
'automation_id': automation_id,
|
||||
'folder_id': form_data.pop('folder_id', None),
|
||||
'filter_ids': form_data.pop('filter_ids', []),
|
||||
'tool_ids': form_data.get('tool_ids', None),
|
||||
|
|
@ -1205,6 +1269,7 @@ async def chat_completion(
|
|||
or model_info_params.get('function_calling')
|
||||
or 'native'
|
||||
),
|
||||
'tool_approval_mode': tool_approval_mode,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1218,8 +1283,8 @@ async def chat_completion(
|
|||
if metadata.get('chat_id') and user:
|
||||
chat_id = metadata['chat_id']
|
||||
|
||||
# Gate channel: branch — caller needs write access on the channel
|
||||
# and the supplied message_id must belong to that channel.
|
||||
# Gate channel: branch — caller needs write access on the channel, and the
|
||||
# supplied message_id must belong to that channel and be the caller's own.
|
||||
if chat_id.startswith('channel:'):
|
||||
channel_id = chat_id.removeprefix('channel:')
|
||||
channel = await Channels.get_channel_by_id(channel_id)
|
||||
|
|
@ -1251,7 +1316,11 @@ async def chat_completion(
|
|||
if not target_message_id:
|
||||
continue
|
||||
target_message = await Messages.get_message_by_id(target_message_id)
|
||||
if target_message and target_message.channel_id != channel.id:
|
||||
if target_message and (
|
||||
target_message.channel_id != channel.id
|
||||
# Write access is not authorship — block cross-member edits.
|
||||
or (user.role != 'admin' and target_message.user_id != user.id)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
|
|
@ -1259,6 +1328,14 @@ async def chat_completion(
|
|||
|
||||
if is_saved_chat_id(chat_id):
|
||||
if is_new_chat:
|
||||
# The chat created below is persisted with this folder_id.
|
||||
folder_id = metadata['folder_id']
|
||||
if folder_id is not None and not await has_folder_write_access(user.id, folder_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Build the full history upfront with ALL assistant placeholders
|
||||
user_message = metadata.get('user_message') or {}
|
||||
user_message_id = user_message.get('id') if user_message else None
|
||||
|
|
@ -1365,7 +1442,7 @@ async def chat_completion(
|
|||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Error inserting chat files: {e}')
|
||||
log.debug('Error inserting chat files: %s', e)
|
||||
pass
|
||||
|
||||
if initial_title_generation is not None and all_assistant_ids:
|
||||
|
|
@ -1387,7 +1464,7 @@ async def chat_completion(
|
|||
try:
|
||||
await background_tasks_handler(title_ctx)
|
||||
except Exception as e:
|
||||
log.debug(f'Error generating initial chat title: {e}')
|
||||
log.debug('Error generating initial chat title: %s', e)
|
||||
|
||||
asyncio.create_task(run_initial_title_generation())
|
||||
else:
|
||||
|
|
@ -1407,15 +1484,13 @@ async def chat_completion(
|
|||
# The old frontend saveChatHandler did this on every message;
|
||||
# now the backend owns persistence.
|
||||
chat_files = metadata.get('files')
|
||||
if chat_files is not None or selected_chat_models:
|
||||
existing_chat = await Chats.get_chat_by_id(chat_id)
|
||||
if existing_chat:
|
||||
updated = {**existing_chat.chat}
|
||||
if chat_files is not None:
|
||||
updated['files'] = chat_files
|
||||
if selected_chat_models:
|
||||
updated['models'] = selected_chat_models
|
||||
await Chats.update_chat_by_id(chat_id, updated, touch=False)
|
||||
chat_fields = {}
|
||||
if chat_files is not None:
|
||||
chat_fields['files'] = chat_files
|
||||
if selected_chat_models:
|
||||
chat_fields['models'] = selected_chat_models
|
||||
if chat_fields:
|
||||
await Chats.update_chat_by_id(chat_id, chat_fields, touch=False)
|
||||
|
||||
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
|
||||
|
||||
|
|
@ -1475,7 +1550,7 @@ async def chat_completion(
|
|||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Error inserting chat files: {e}')
|
||||
log.debug('Error inserting chat files: %s', e)
|
||||
pass
|
||||
|
||||
# Save ALL assistant placeholders
|
||||
|
|
@ -1500,6 +1575,8 @@ async def chat_completion(
|
|||
for entry in message_ids:
|
||||
target_model_id = entry['model_id']
|
||||
assistant_message_id = entry['message_id']
|
||||
if assistant_message_id and assistant_message_id == metadata.get('assistant_message_id'):
|
||||
continue
|
||||
if assistant_message_id:
|
||||
assistant_message = {
|
||||
'id': assistant_message_id,
|
||||
|
|
@ -1548,6 +1625,9 @@ async def chat_completion(
|
|||
try:
|
||||
form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model)
|
||||
|
||||
if await drain_approved_tool_calls(request, form_data, user, model, metadata):
|
||||
return {'status': True, 'chat_id': metadata.get('chat_id'), 'paused': True}
|
||||
|
||||
response = await chat_completion_handler(request, form_data, user)
|
||||
|
||||
# When the upstream provider returns an error (e.g. HTTP 400
|
||||
|
|
@ -1556,14 +1636,7 @@ async def chat_completion(
|
|||
# raise so the except-block below emits chat:message:error +
|
||||
# chat:tasks:cancel, unblocking the frontend.
|
||||
if isinstance(response, JSONResponse) and response.status_code >= 400:
|
||||
try:
|
||||
error_body = json.loads(response.body.decode('utf-8', 'replace'))
|
||||
detail = error_body.get('error', error_body) if isinstance(error_body, dict) else error_body
|
||||
if isinstance(detail, dict):
|
||||
detail = detail.get('message', detail.get('detail', str(detail)))
|
||||
except Exception:
|
||||
detail = f'Provider returned HTTP {response.status_code}'
|
||||
raise Exception(detail)
|
||||
raise Exception(get_response_error_detail(response))
|
||||
|
||||
ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events)
|
||||
|
||||
|
|
@ -1641,9 +1714,9 @@ async def chat_completion(
|
|||
try:
|
||||
await client.disconnect()
|
||||
except BaseException as e:
|
||||
log.debug(f'Error disconnecting MCP client: {e}')
|
||||
log.debug('Error disconnecting MCP client: %s', e)
|
||||
except BaseException as e:
|
||||
log.debug(f'Error cleaning up MCP clients: {e}')
|
||||
log.debug('Error cleaning up MCP clients: %s', e)
|
||||
|
||||
# Deregister this task, then emit chat:active=false if no others remain
|
||||
try:
|
||||
|
|
@ -1789,6 +1862,27 @@ async def chat_completion(
|
|||
generate_chat_completions = chat_completion
|
||||
generate_chat_completion = chat_completion
|
||||
|
||||
|
||||
@app.post('/api/v1/chats/{id}/messages/{message_id}/resolve')
|
||||
async def resolve_chat_message_tool_call(
|
||||
request: Request,
|
||||
id: str,
|
||||
message_id: str,
|
||||
form_data: ResolveToolCallForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
resolution = await resolve_tool_call_output(id, message_id, form_data, user, db=db)
|
||||
payload = await build_tool_approval_resume_payload(id, message_id, chat=resolution['chat'])
|
||||
result = await chat_completion(request, payload, user)
|
||||
return {
|
||||
'status': True,
|
||||
'chat_id': id,
|
||||
'message_id': message_id,
|
||||
**(result if isinstance(result, dict) else {}),
|
||||
}
|
||||
|
||||
|
||||
# Expose as app.state so internal callers (e.g. automations) can
|
||||
# use the full pipeline without importing from main.py (avoids circular deps).
|
||||
app.state.CHAT_COMPLETION_HANDLER = chat_completion
|
||||
|
|
@ -1820,7 +1914,7 @@ async def count_message_tokens(
|
|||
|
||||
|
||||
async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict:
|
||||
requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target(
|
||||
requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_request_target(
|
||||
request, form_data, user
|
||||
)
|
||||
request_url = f'{url.rstrip("/")}/messages'
|
||||
|
|
@ -1832,11 +1926,11 @@ async def passthrough_anthropic_messages(request: Request, form_data: dict, user
|
|||
response = await session.request(
|
||||
method='POST',
|
||||
url=request_url,
|
||||
data=json.dumps(payload),
|
||||
data=JSONCodec.dumps(payload),
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=bool(payload.get('stream'))),
|
||||
)
|
||||
|
||||
if 'text/event-stream' in response.headers.get('Content-Type', ''):
|
||||
|
|
@ -1925,13 +2019,6 @@ async def generate_messages(
|
|||
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
|
||||
model_meta = model_info.meta.model_dump() if model_info and model_info.meta else {}
|
||||
if (model_meta.get('capabilities') or {}).get('usage') is True:
|
||||
if openai_payload.get('stream'):
|
||||
stream_options = openai_payload.get('stream_options')
|
||||
if not isinstance(stream_options, dict):
|
||||
stream_options = {}
|
||||
openai_payload['stream_options'] = {**stream_options, 'include_usage': True}
|
||||
|
||||
# Route through the existing chat_completion handler
|
||||
response = await chat_completion(request, openai_payload, user)
|
||||
|
|
@ -2039,13 +2126,14 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
|
|||
|
||||
task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id)
|
||||
|
||||
log.debug(f'Task IDs for chat {chat_id}: {task_ids}')
|
||||
log.debug('Task IDs for chat %s: %s', chat_id, task_ids)
|
||||
return {'task_ids': task_ids}
|
||||
|
||||
|
||||
@app.post('/api/tasks/chat/{chat_id:path}/stop')
|
||||
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
|
||||
socket_id = get_temporary_chat_session_id(chat_id)
|
||||
chat = None
|
||||
if socket_id:
|
||||
owner_id = get_user_id_from_session_pool(socket_id)
|
||||
if owner_id != user.id and user.role != 'admin':
|
||||
|
|
@ -2055,6 +2143,47 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
|
|||
if chat is None or (chat.user_id != user.id and user.role != 'admin'):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
result = await stop_item_tasks(request.app.state.redis, chat_id)
|
||||
|
||||
if not socket_id and str(result.get('message', '')).startswith('No tasks found'):
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(chat_id) or {}
|
||||
for message_id, message in messages_map.items():
|
||||
if message.get('role') != 'assistant' or message.get('done') is not False:
|
||||
continue
|
||||
|
||||
output = message.get('output')
|
||||
if isinstance(output, list):
|
||||
for item in output:
|
||||
if item.get('type') == 'function_call' and item.get('status') in {
|
||||
'pending',
|
||||
'queued',
|
||||
'requires_approval',
|
||||
}:
|
||||
item['status'] = 'rejected'
|
||||
item.pop('approved', None)
|
||||
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
message_id,
|
||||
{'done': True, **({'output': output} if isinstance(output, list) else {})},
|
||||
touch=False,
|
||||
)
|
||||
result = {
|
||||
'status': True,
|
||||
'message': 'Finalized pending approval message.',
|
||||
}
|
||||
|
||||
event_emitter = await get_event_emitter(
|
||||
{
|
||||
'user_id': chat.user_id,
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
},
|
||||
update_db=False,
|
||||
)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:completion', 'data': {'done': True, 'output': output}})
|
||||
await event_emitter({'type': 'chat:tasks:cancel'})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -2113,6 +2242,7 @@ async def get_app_config(request: Request):
|
|||
'automations.enable',
|
||||
'notes.enable',
|
||||
'chat.context_compaction.enable',
|
||||
'chat.tool_permissions.enable',
|
||||
'web.search.enable',
|
||||
'web.search.confirmation.enable',
|
||||
'web.search.confirmation.content',
|
||||
|
|
@ -2129,6 +2259,7 @@ async def get_app_config(request: Request):
|
|||
'memories.enable',
|
||||
'ui.default_models',
|
||||
'ui.default_pinned_models',
|
||||
'ui.default_interface_settings',
|
||||
'ui.prompt_suggestions',
|
||||
'code_execution.engine',
|
||||
'code_interpreter.engine',
|
||||
|
|
@ -2171,6 +2302,11 @@ async def get_app_config(request: Request):
|
|||
'enable_signup': config.get('ui.enable_signup'),
|
||||
'enable_login_form': config.get('ui.enable_login_form'),
|
||||
'enable_websocket': ENABLE_WEBSOCKET_SUPPORT,
|
||||
**(
|
||||
{'websocket_heartbeat_interval': WEBSOCKET_HEARTBEAT_INTERVAL}
|
||||
if WEBSOCKET_HEARTBEAT_INTERVAL is not None
|
||||
else {}
|
||||
),
|
||||
# --- Authenticated: only consumed by logged-in frontend ---
|
||||
**(
|
||||
{
|
||||
|
|
@ -2189,6 +2325,7 @@ async def get_app_config(request: Request):
|
|||
'enable_automations': config.get('automations.enable'),
|
||||
'enable_notes': config.get('notes.enable'),
|
||||
'enable_context_compaction': config.get('chat.context_compaction.enable'),
|
||||
'enable_tool_permissions': config.get('chat.tool_permissions.enable'),
|
||||
'enable_web_search': config.get('web.search.enable'),
|
||||
'enable_web_search_confirmation': config.get('web.search.confirmation.enable'),
|
||||
'web_search_confirmation_content': config.get('web.search.confirmation.content'),
|
||||
|
|
@ -2259,6 +2396,7 @@ async def get_app_config(request: Request):
|
|||
'sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
},
|
||||
'ui': {
|
||||
'default_interface_settings': config.get('ui.default_interface_settings'),
|
||||
'pending_user_overlay_title': config.get('ui.pending_user_overlay_title'),
|
||||
'pending_user_overlay_content': config.get('ui.pending_user_overlay_content'),
|
||||
'response_watermark': config.get('ui.watermark'),
|
||||
|
|
@ -2559,8 +2697,19 @@ async def register_client(request, client_id: str) -> bool:
|
|||
oauth_server_key,
|
||||
oauth_scope=oauth_scope,
|
||||
)
|
||||
except InvalidToken:
|
||||
log.error(
|
||||
'OAuth client re-registration failed for %s: InvalidToken. '
|
||||
'Stored OAuth client data is invalid; reconnect this tool server.',
|
||||
client_id,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(f'OAuth client re-registration failed for {client_id}: {e}')
|
||||
log.error(
|
||||
'OAuth client re-registration failed for %s: %s',
|
||||
client_id,
|
||||
f'{type(e).__name__}: {e}' if str(e) else type(e).__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
|
|
@ -2582,7 +2731,7 @@ async def register_client(request, client_id: str) -> bool:
|
|||
**apply_connection_oauth_options(connection, oauth_client_info.model_dump(mode='json'))
|
||||
)
|
||||
oauth_client_manager.add_client(client_id, oauth_client_info)
|
||||
log.info(f'Re-registered OAuth client {client_id} for tool server')
|
||||
log.info('Re-registered OAuth client %s for tool server', client_id)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -2626,7 +2775,7 @@ async def oauth_client_authorize(
|
|||
detail='OAuth client registration is still invalid after re-registration',
|
||||
)
|
||||
|
||||
return await oauth_client_manager.handle_authorize(request, client_id=client_id)
|
||||
return await oauth_client_manager.handle_authorize(request, client_id=client_id, user_id=user.id)
|
||||
|
||||
|
||||
@app.get('/oauth/clients/{client_id}/callback')
|
||||
|
|
@ -2634,12 +2783,10 @@ async def oauth_client_callback(
|
|||
client_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return await oauth_client_manager.handle_callback(
|
||||
request,
|
||||
client_id=client_id,
|
||||
user_id=user.id if user else None,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
|
@ -2688,6 +2835,10 @@ async def oauth_backchannel_logout(
|
|||
async def get_manifest_json():
|
||||
external_pwa_manifest_url = getattr(app.state, 'EXTERNAL_PWA_MANIFEST_URL', None)
|
||||
if external_pwa_manifest_url:
|
||||
# LICENSE covers this install-time Open WebUI branding surface, including
|
||||
# names, logos, manifests, metadata, and surrounding UI.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
external_pwa_manifest_url,
|
||||
|
|
@ -2696,6 +2847,10 @@ async def get_manifest_json():
|
|||
r.raise_for_status()
|
||||
return await r.json()
|
||||
else:
|
||||
# LICENSE covers this generated Open WebUI install branding surface,
|
||||
# including names, logos, manifests, metadata, and surrounding UI.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
return {
|
||||
'name': app.state.WEBUI_NAME,
|
||||
'short_name': app.state.WEBUI_NAME,
|
||||
|
|
@ -2704,6 +2859,9 @@ async def get_manifest_json():
|
|||
'display': 'standalone',
|
||||
'background_color': '#343541',
|
||||
'icons': [
|
||||
# LICENSE covers this Open WebUI install icon.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
{
|
||||
'src': '/static/logo.png',
|
||||
'type': 'image/png',
|
||||
|
|
@ -2728,6 +2886,9 @@ async def get_manifest_json():
|
|||
@app.get('/opensearch.xml')
|
||||
async def get_opensearch_xml():
|
||||
webui_url = await Config.get('webui.url')
|
||||
# LICENSE covers this Open WebUI search identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
xml_content = rf"""
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">
|
||||
<ShortName>{app.state.WEBUI_NAME}</ShortName>
|
||||
|
|
@ -2745,7 +2906,7 @@ def _sync_db_ping() -> None:
|
|||
"""Verify the database is reachable with a simple SELECT 1.
|
||||
|
||||
Uses a raw connection from the engine pool instead of the thread-local
|
||||
ScopedSession. This is necessary because CommitSessionMiddleware
|
||||
ScopedSession. This is necessary because AppHTTPMiddleware
|
||||
deliberately skips healthcheck paths (/health, /ready, /health/db),
|
||||
so any ScopedSession opened on a healthcheck worker thread is never
|
||||
rolled back or removed. If the session ever enters an invalid state
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"""Add group_member user_id index
|
||||
|
||||
Revision ID: 1ce6ade7d93b
|
||||
Revises: f0bd01a18a3d
|
||||
Create Date: 2026-07-31 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '1ce6ade7d93b'
|
||||
down_revision = 'f0bd01a18a3d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_indexes = {idx['name'] for idx in inspector.get_indexes('group_member')}
|
||||
|
||||
if 'ix_group_member_user_id_group_id' not in existing_indexes:
|
||||
op.create_index('ix_group_member_user_id_group_id', 'group_member', ['user_id', 'group_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_group_member_user_id_group_id', table_name='group_member')
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""repair double encoded user oauth
|
||||
|
||||
Revision ID: 6d09d1bf1f23
|
||||
Revises: 1ce6ade7d93b
|
||||
Create Date: 2026-08-10 23:20:20.374826
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6d09d1bf1f23'
|
||||
down_revision: Union[str, None] = '1ce6ade7d93b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
_user = sa.table(
|
||||
'user',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('oauth', sa.JSON),
|
||||
)
|
||||
|
||||
|
||||
def _decode_json_object(value: str) -> dict | None:
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
return decoded if isinstance(decoded, dict) else None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if 'user' not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
user_columns = {c['name'] for c in inspector.get_columns('user')}
|
||||
if 'oauth' not in user_columns:
|
||||
return
|
||||
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
|
||||
|
||||
for uid, oauth in rows:
|
||||
if not isinstance(oauth, str):
|
||||
continue
|
||||
|
||||
decoded = _decode_json_object(oauth)
|
||||
if decoded is None:
|
||||
continue
|
||||
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth=decoded))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
|
|
@ -185,9 +185,7 @@ def upgrade() -> None:
|
|||
for uid, oauth_sub in rows:
|
||||
if oauth_sub:
|
||||
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
|
||||
conn.execute(
|
||||
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
|
||||
)
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth={provider: {'sub': sub}}))
|
||||
|
||||
# ── Migrate api_key column → api_key table (only if old column still exists)
|
||||
if 'api_key' in user_columns:
|
||||
|
|
@ -226,7 +224,7 @@ def downgrade() -> None:
|
|||
|
||||
for uid, oauth in rows:
|
||||
try:
|
||||
data = json.loads(oauth)
|
||||
data = oauth if isinstance(oauth, dict) else json.loads(oauth)
|
||||
provider = list(data.keys())[0]
|
||||
sub = data[provider].get('sub')
|
||||
oauth_sub = f'{provider}@{sub}'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
"""add chat timer_at and chat list, unread and timer indexes
|
||||
|
||||
Revision ID: d4c1a8e37b62
|
||||
Revises: 6d09d1bf1f23
|
||||
Create Date: 2026-08-23 18:05:12.441907
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd4c1a8e37b62'
|
||||
down_revision: str | None = '6d09d1bf1f23'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('chat', sa.Column('timer_at', sa.BigInteger(), nullable=True))
|
||||
op.create_index(
|
||||
'timer_at_idx',
|
||||
'chat',
|
||||
['timer_at'],
|
||||
sqlite_where=sa.text('timer_at IS NOT NULL'),
|
||||
postgresql_where=sa.text('timer_at IS NOT NULL'),
|
||||
)
|
||||
|
||||
# Timers created before this migration carry their due time in meta only, and would never fire.
|
||||
chat = sa.table(
|
||||
'chat', sa.column('id', sa.String), sa.column('meta', sa.JSON), sa.column('timer_at', sa.BigInteger)
|
||||
)
|
||||
conn = op.get_bind()
|
||||
pending = conn.execute(
|
||||
sa.select(chat.c.id, chat.c.meta)
|
||||
.where(chat.c.meta['type'].as_string() == 'timer')
|
||||
.where(chat.c.meta['status'].as_string() == 'pending')
|
||||
).all()
|
||||
for chat_id, meta in pending:
|
||||
try: # imported chats can carry any meta, and a non-numeric due time must not abort the migration
|
||||
due_at = int(meta.get('timer_at'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
conn.execute(chat.update().where(chat.c.id == chat_id).values(timer_at=due_at))
|
||||
|
||||
op.create_index('user_id_updated_at_id_idx', 'chat', ['user_id', sa.text('updated_at DESC'), 'id'])
|
||||
op.create_index(
|
||||
'user_id_timer_at_idx',
|
||||
'chat',
|
||||
['user_id', 'timer_at'],
|
||||
sqlite_where=sa.text('timer_at IS NOT NULL'),
|
||||
postgresql_where=sa.text('timer_at IS NOT NULL'),
|
||||
)
|
||||
op.create_index(
|
||||
'user_id_folder_unread_idx',
|
||||
'chat',
|
||||
['user_id', 'folder_id', 'archived', 'updated_at', 'last_read_at', 'id'],
|
||||
)
|
||||
op.create_index('chat_message_chat_role_done_idx', 'chat_message', ['chat_id', 'role', 'done'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('chat_message_chat_role_done_idx', table_name='chat_message')
|
||||
op.drop_index('user_id_folder_unread_idx', table_name='chat')
|
||||
op.drop_index('user_id_timer_at_idx', table_name='chat')
|
||||
op.drop_index('user_id_updated_at_id_idx', table_name='chat')
|
||||
op.drop_index('timer_at_idx', table_name='chat')
|
||||
op.drop_column('chat', 'timer_at')
|
||||
|
|
@ -839,7 +839,8 @@ class AccessGrantsTable:
|
|||
):
|
||||
"""
|
||||
Filter for items where user has read BUT NOT write access.
|
||||
Public items are NOT considered read_only.
|
||||
A public (user:*) read grant counts as read access, so publicly shared
|
||||
read-only items are listed rather than being reachable only by direct link.
|
||||
|
||||
Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself,
|
||||
so it remains synchronous. The caller is responsible for executing the query
|
||||
|
|
@ -850,7 +851,6 @@ class AccessGrantsTable:
|
|||
|
||||
from sqlalchemy import exists as sa_exists
|
||||
|
||||
# Has read grant (not public)
|
||||
read_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
|
|
@ -858,6 +858,10 @@ class AccessGrantsTable:
|
|||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == 'read',
|
||||
or_(
|
||||
and_(
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
|
|
@ -884,7 +888,6 @@ class AccessGrantsTable:
|
|||
.exists()
|
||||
)
|
||||
|
||||
# Does NOT have write grant
|
||||
write_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
|
|
@ -892,6 +895,10 @@ class AccessGrantsTable:
|
|||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == 'write',
|
||||
or_(
|
||||
and_(
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
|
|
@ -918,21 +925,7 @@ class AccessGrantsTable:
|
|||
.exists()
|
||||
)
|
||||
|
||||
# Is NOT public
|
||||
public_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == 'read',
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
|
||||
conditions = [read_grant_exists, ~write_grant_exists]
|
||||
|
||||
# Not owner
|
||||
if user_id:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.utils.misc import json_text_variants
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -64,11 +65,17 @@ class AutomationTerminalConfig(BaseModel):
|
|||
cwd: Optional[str] = None
|
||||
|
||||
|
||||
class AutomationTarget(BaseModel):
|
||||
type: Literal['chat', 'channel'] = 'chat'
|
||||
channel_id: Optional[str] = None
|
||||
|
||||
|
||||
class AutomationData(BaseModel):
|
||||
prompt: str
|
||||
model_id: str
|
||||
rrule: str
|
||||
terminal: Optional[AutomationTerminalConfig] = None
|
||||
target: Optional[AutomationTarget] = None
|
||||
|
||||
|
||||
class AutomationModel(BaseModel):
|
||||
|
|
@ -179,16 +186,16 @@ class AutomationTable:
|
|||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Automation).filter_by(user_id=user_id)
|
||||
|
||||
if folder_id is not None:
|
||||
stmt = stmt.filter(Automation.folder_id == (folder_id or None))
|
||||
if folder_id:
|
||||
stmt = stmt.filter(Automation.folder_id == folder_id)
|
||||
|
||||
if query:
|
||||
search = f'%{query}%'
|
||||
# Search in name and prompt inside JSON data
|
||||
# Search the name column and the prompt inside the JSON data.
|
||||
data_text = cast(Automation.data, String)
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
Automation.name.ilike(search),
|
||||
cast(Automation.data, String).ilike(search),
|
||||
Automation.name.ilike(f'%{query}%'),
|
||||
*(data_text.ilike(f'%{variant}%') for variant in json_text_variants(query)),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -734,10 +734,10 @@ class CalendarEventTable:
|
|||
events = []
|
||||
for event, tz in rows:
|
||||
model = CalendarEventModel.model_validate(event)
|
||||
# Determine per-event alert window
|
||||
alert_minutes = None
|
||||
if model.meta and 'alert_minutes' in model.meta:
|
||||
alert_minutes = model.meta['alert_minutes']
|
||||
# meta is user-writable and this poll is shared by every user.
|
||||
alert_minutes = (model.meta or {}).get('alert_minutes')
|
||||
if not isinstance(alert_minutes, (int, float)):
|
||||
alert_minutes = None
|
||||
|
||||
if alert_minutes is not None:
|
||||
if alert_minutes < 0:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -10,6 +9,7 @@ from open_webui.models.access_grants import (
|
|||
AccessGrants,
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy import (
|
||||
|
|
@ -438,17 +438,17 @@ class ChannelTable:
|
|||
|
||||
match_count = func.sum(
|
||||
case(
|
||||
(ChannelMember.user_id.in_(unique_user_ids), 1),
|
||||
(User.id.in_(unique_user_ids), 1),
|
||||
else_=0,
|
||||
)
|
||||
)
|
||||
|
||||
subquery = (
|
||||
select(ChannelMember.channel_id)
|
||||
.join(User, User.id == ChannelMember.user_id)
|
||||
.group_by(ChannelMember.channel_id)
|
||||
# 1. Channel must have exactly len(user_ids) members
|
||||
.having(func.count(ChannelMember.user_id) == len(unique_user_ids))
|
||||
# 2. All those members must be in unique_user_ids
|
||||
# Match the exact set of accounts that still exist.
|
||||
.having(func.count(User.id) == len(unique_user_ids))
|
||||
.having(match_count == len(unique_user_ids))
|
||||
.subquery()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter
|
||||
|
|
@ -169,6 +168,7 @@ class ChatMessage(Base):
|
|||
Index('chat_message_chat_parent_idx', 'chat_id', 'parent_id'),
|
||||
Index('chat_message_model_created_idx', 'model_id', 'created_at'),
|
||||
Index('chat_message_user_created_idx', 'user_id', 'created_at'),
|
||||
Index('chat_message_chat_role_done_idx', 'chat_id', 'role', 'done'), # unfinished-assistant probe
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -207,6 +207,66 @@ class ChatMessageModel(BaseModel):
|
|||
|
||||
|
||||
class ChatMessageTable:
|
||||
@staticmethod
|
||||
def _apply_message_data(message: ChatMessage, data: dict, now: int) -> None:
|
||||
"""Overwrite only the fields the payload carries."""
|
||||
if 'role' in data:
|
||||
message.role = data['role']
|
||||
if 'parent_id' in data or 'parentId' in data:
|
||||
message.parent_id = data.get('parent_id') or data.get('parentId')
|
||||
if 'content' in data:
|
||||
message.content = data.get('content')
|
||||
if 'output' in data:
|
||||
message.output = data.get('output')
|
||||
if 'model_id' in data or 'model' in data:
|
||||
message.model_id = data.get('model_id') or data.get('model')
|
||||
if 'files' in data:
|
||||
message.files = data.get('files')
|
||||
if 'sources' in data:
|
||||
message.sources = data.get('sources')
|
||||
if 'embeds' in data:
|
||||
message.embeds = data.get('embeds')
|
||||
if 'meta' in data:
|
||||
message.meta = data.get('meta')
|
||||
if 'done' in data:
|
||||
message.done = data['done']
|
||||
if 'status_history' in data or 'statusHistory' in data:
|
||||
message.status_history = data.get('status_history') or data.get('statusHistory')
|
||||
if 'error' in data:
|
||||
message.error = data.get('error')
|
||||
if 'context_summary' in data or 'contextSummary' in data:
|
||||
message.context_summary = data.get('context_summary') or data.get('contextSummary')
|
||||
|
||||
usage = get_usage(data)
|
||||
if usage:
|
||||
existing_usage = normalize_usage(message.usage)
|
||||
message.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
|
||||
message.updated_at = now
|
||||
|
||||
@staticmethod
|
||||
def _build_message(composite_id: str, chat_id: str, user_id: str, data: dict, now: int) -> ChatMessage:
|
||||
return ChatMessage(
|
||||
id=composite_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
role=data.get('role', 'user'),
|
||||
parent_id=data.get('parent_id') or data.get('parentId'),
|
||||
content=data.get('content'),
|
||||
output=data.get('output'),
|
||||
model_id=data.get('model_id') or data.get('model'),
|
||||
files=data.get('files'),
|
||||
sources=data.get('sources'),
|
||||
embeds=data.get('embeds'),
|
||||
meta=data.get('meta'),
|
||||
done=data.get('done', True),
|
||||
status_history=data.get('status_history') or data.get('statusHistory'),
|
||||
error=data.get('error'),
|
||||
usage=get_usage(data),
|
||||
context_summary=data.get('context_summary') or data.get('contextSummary'),
|
||||
created_at=data.get('timestamp', now),
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async def upsert_message(
|
||||
self,
|
||||
message_id: str,
|
||||
|
|
@ -218,76 +278,46 @@ class ChatMessageTable:
|
|||
"""Insert or update a chat message."""
|
||||
async with get_async_db_context(db) as db:
|
||||
now = int(time.time())
|
||||
timestamp = data.get('timestamp', now)
|
||||
|
||||
# Use composite ID: {chat_id}-{message_id}
|
||||
composite_id = f'{chat_id}-{message_id}'
|
||||
|
||||
existing = await db.get(ChatMessage, composite_id)
|
||||
if existing:
|
||||
# Update existing
|
||||
if 'role' in data:
|
||||
existing.role = data['role']
|
||||
if 'parent_id' in data or 'parentId' in data:
|
||||
existing.parent_id = data.get('parent_id') or data.get('parentId')
|
||||
if 'content' in data:
|
||||
existing.content = data.get('content')
|
||||
if 'output' in data:
|
||||
existing.output = data.get('output')
|
||||
if 'model_id' in data or 'model' in data:
|
||||
existing.model_id = data.get('model_id') or data.get('model')
|
||||
if 'files' in data:
|
||||
existing.files = data.get('files')
|
||||
if 'sources' in data:
|
||||
existing.sources = data.get('sources')
|
||||
if 'embeds' in data:
|
||||
existing.embeds = data.get('embeds')
|
||||
if 'meta' in data:
|
||||
existing.meta = data.get('meta')
|
||||
if 'done' in data:
|
||||
existing.done = data.get('done', True)
|
||||
if 'status_history' in data or 'statusHistory' in data:
|
||||
existing.status_history = data.get('status_history') or data.get('statusHistory')
|
||||
if 'error' in data:
|
||||
existing.error = data.get('error')
|
||||
if 'context_summary' in data or 'contextSummary' in data:
|
||||
existing.context_summary = data.get('context_summary') or data.get('contextSummary')
|
||||
# Extract and normalize usage
|
||||
usage = get_usage(data)
|
||||
if usage:
|
||||
existing_usage = normalize_usage(existing.usage or {}) if existing.usage else {}
|
||||
existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
|
||||
existing.updated_at = now
|
||||
await db.commit()
|
||||
return ChatMessageModel.model_validate(existing)
|
||||
message = await db.get(ChatMessage, composite_id)
|
||||
if message:
|
||||
self._apply_message_data(message, data, now)
|
||||
else:
|
||||
# Insert new
|
||||
# Extract and normalize usage
|
||||
usage = get_usage(data)
|
||||
message = ChatMessage(
|
||||
id=composite_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
role=data.get('role', 'user'),
|
||||
parent_id=data.get('parent_id') or data.get('parentId'),
|
||||
content=data.get('content'),
|
||||
output=data.get('output'),
|
||||
model_id=data.get('model_id') or data.get('model'),
|
||||
files=data.get('files'),
|
||||
sources=data.get('sources'),
|
||||
embeds=data.get('embeds'),
|
||||
meta=data.get('meta'),
|
||||
done=data.get('done', True),
|
||||
status_history=data.get('status_history') or data.get('statusHistory'),
|
||||
error=data.get('error'),
|
||||
usage=usage,
|
||||
context_summary=data.get('context_summary') or data.get('contextSummary'),
|
||||
created_at=timestamp,
|
||||
updated_at=now,
|
||||
)
|
||||
message = self._build_message(composite_id, chat_id, user_id, data, now)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
return ChatMessageModel.model_validate(message)
|
||||
|
||||
await db.commit()
|
||||
return ChatMessageModel.model_validate(message)
|
||||
|
||||
async def upsert_messages(
|
||||
self,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
messages: dict[str, dict],
|
||||
db: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Insert or update the given messages of one chat."""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
now = int(time.time())
|
||||
result = await db.execute(
|
||||
select(ChatMessage).filter(ChatMessage.id.in_([f'{chat_id}-{message_id}' for message_id in messages]))
|
||||
)
|
||||
existing_by_id = {row.id: row for row in result.scalars().all()}
|
||||
|
||||
for message_id, data in messages.items():
|
||||
composite_id = f'{chat_id}-{message_id}'
|
||||
message = existing_by_id.get(composite_id)
|
||||
if message:
|
||||
self._apply_message_data(message, data, now)
|
||||
else:
|
||||
db.add(self._build_message(composite_id, chat_id, user_id, data, now))
|
||||
|
||||
await db.commit()
|
||||
|
||||
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
# local imports
|
||||
from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.automations import AutomationRun
|
||||
from open_webui.models.chat_messages import ChatMessage, ChatMessages
|
||||
from open_webui.models.folders import Folders
|
||||
|
|
@ -41,6 +44,62 @@ from sqlalchemy.sql.expression import bindparam
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
ACTIVE_CHAT_GAP_SECONDS = 30 * 60
|
||||
CHAT_SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
|
||||
|
||||
|
||||
def chat_search_content_query(text: str) -> str:
|
||||
words = sanitize_text_for_db(text).lower().strip().split()
|
||||
return ' '.join(word for word in words if not word.startswith(CHAT_SEARCH_FILTER_PREFIXES)).strip()
|
||||
|
||||
|
||||
def chat_search_terms(text: str) -> list[str]:
|
||||
return list(dict.fromkeys(re.findall(r'[a-z0-9]+', text.lower())))
|
||||
|
||||
|
||||
def chat_search_message_content_match_sql(dialect_name: str, key: str) -> str:
|
||||
if dialect_name == 'sqlite':
|
||||
return f"""
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat, '$.history.messages') AS history_message
|
||||
WHERE LOWER(history_message.value->>'content') LIKE '%' || :{key} || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat, '$.messages') AS legacy_message
|
||||
WHERE LOWER(legacy_message.value->>'content') LIKE '%' || :{key} || '%'
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
if dialect_name == 'postgresql':
|
||||
return f"""
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM chat_message AS message
|
||||
WHERE message.chat_id = Chat.id
|
||||
AND message.user_id = Chat.user_id
|
||||
AND json_typeof(message.content) = 'string'
|
||||
AND LOWER(message.content #>> '{{}}') LIKE '%' || :{key} || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat#>'{{history,messages}}') AS history_message
|
||||
WHERE json_typeof(history_message.value->'content') = 'string'
|
||||
AND LOWER(history_message.value->>'content') LIKE '%' || :{key} || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
|
||||
WHERE json_typeof(legacy_message->'content') = 'string'
|
||||
AND LOWER(legacy_message->>'content') LIKE '%' || :{key} || '%'
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
|
||||
|
||||
|
||||
def chat_list_order(sort_by: str = 'updated_at', sort_dir: str = 'desc', user_id: str | None = None):
|
||||
|
|
@ -91,6 +150,7 @@ class Chat(Base): # database table mapping for chat entity
|
|||
current_message_id = Column(Text, nullable=True)
|
||||
|
||||
last_read_at = Column(BigInteger, nullable=True)
|
||||
timer_at = Column(BigInteger, nullable=True) # ns due time, set only while a timer chat waits to be claimed
|
||||
|
||||
__table_args__ = (
|
||||
# Performance indexes for common queries
|
||||
|
|
@ -99,6 +159,23 @@ class Chat(Base): # database table mapping for chat entity
|
|||
Index('user_id_archived_idx', 'user_id', 'archived'),
|
||||
Index('updated_at_user_id_idx', 'updated_at', 'user_id'),
|
||||
Index('folder_id_user_id_idx', 'folder_id', 'user_id'),
|
||||
Index('user_id_updated_at_id_idx', 'user_id', updated_at.desc(), 'id'),
|
||||
Index(
|
||||
'timer_at_idx',
|
||||
'timer_at',
|
||||
sqlite_where=text('timer_at IS NOT NULL'),
|
||||
postgresql_where=text('timer_at IS NOT NULL'),
|
||||
),
|
||||
# timer_at key column turns the IS NOT NULL into a seek, so this beats the plain user_id indexes
|
||||
Index(
|
||||
'user_id_timer_at_idx',
|
||||
'user_id',
|
||||
'timer_at',
|
||||
sqlite_where=text('timer_at IS NOT NULL'),
|
||||
postgresql_where=text('timer_at IS NOT NULL'),
|
||||
),
|
||||
# covering index: lets SQLite serve count_unread_by_folder_ids without reading chat rows
|
||||
Index('user_id_folder_unread_idx', 'user_id', 'folder_id', 'archived', 'updated_at', 'last_read_at', 'id'),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -129,6 +206,7 @@ class ChatModel(BaseModel):
|
|||
current_message_id: str | None = None
|
||||
|
||||
last_read_at: int | None = None
|
||||
timer_at: int | None = None
|
||||
|
||||
@field_validator('variables', mode='before')
|
||||
@classmethod
|
||||
|
|
@ -342,6 +420,9 @@ class ChatTable:
|
|||
"""
|
||||
Clean a Chat SQLAlchemy model's title + chat JSON,
|
||||
and return True if anything changed.
|
||||
|
||||
The message write paths (upsert/status/delete) rely on this
|
||||
leaving the blob clean and sanitize only the data they add.
|
||||
"""
|
||||
changed = False
|
||||
|
||||
|
|
@ -361,6 +442,20 @@ class ChatTable:
|
|||
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _last_descendant_id(messages: dict, message_id: str) -> str:
|
||||
seen_ids = set()
|
||||
while message_id in messages and message_id not in seen_ids:
|
||||
seen_ids.add(message_id)
|
||||
message = messages[message_id]
|
||||
child_ids = message.get('childrenIds') if isinstance(message, dict) else []
|
||||
child_ids = child_ids if isinstance(child_ids, list) else []
|
||||
next_id = next((child_id for child_id in reversed(child_ids) if child_id in messages), None)
|
||||
if not next_id:
|
||||
break
|
||||
message_id = next_id
|
||||
return message_id
|
||||
|
||||
def _repair_chat_current_id(self, chat: dict) -> bool:
|
||||
history = chat.get('history')
|
||||
if not isinstance(history, dict):
|
||||
|
|
@ -393,6 +488,12 @@ class ChatTable:
|
|||
and current_message.get('role')
|
||||
and not current_is_bad_leaf
|
||||
):
|
||||
if current_message.get('contextSummary') or current_message.get('context_summary'):
|
||||
last_descendant_id = self._last_descendant_id(messages, current_id)
|
||||
if last_descendant_id != current_id:
|
||||
history['currentId'] = last_descendant_id
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
latest_leaf_id = None
|
||||
|
|
@ -421,6 +522,7 @@ class ChatTable:
|
|||
db: AsyncSession | None = None,
|
||||
*,
|
||||
internal_meta: dict | None = None,
|
||||
timer_at: int | None = None,
|
||||
) -> ChatModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat = ChatModel(
|
||||
|
|
@ -433,6 +535,7 @@ class ChatTable:
|
|||
'chat': self._clean_null_bytes(form_data.chat),
|
||||
'folder_id': form_data.folder_id,
|
||||
'meta': internal_meta or {},
|
||||
'timer_at': timer_at,
|
||||
'variables': form_data.variables or {},
|
||||
'current_message_id': self.get_current_message_id(form_data.chat),
|
||||
'created_at': int(time.time()),
|
||||
|
|
@ -596,17 +699,29 @@ class ChatTable:
|
|||
*,
|
||||
touch: bool = True,
|
||||
) -> ChatModel | None:
|
||||
"""Persist updated chat content, sanitizing null bytes."""
|
||||
try: # load the chat record for in-place mutation
|
||||
"""Patch top-level chat keys; history is merged so stale writers don't drop messages."""
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
chat_item = await session.get(
|
||||
Chat,
|
||||
id,
|
||||
populate_existing=True,
|
||||
with_for_update=session.bind.dialect.name == 'postgresql',
|
||||
)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
chat_item.chat = self._clean_null_bytes(chat)
|
||||
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
|
||||
stored = chat_item.chat or {}
|
||||
updated = {**stored, **chat}
|
||||
if 'history' in chat:
|
||||
# The caller built its history from an earlier read; merge so messages saved since then survive.
|
||||
updated['history'] = self.merge_history(stored.get('history'), chat['history'])
|
||||
|
||||
updated = self._clean_null_bytes(updated)
|
||||
chat_item.chat = updated
|
||||
chat_item.title = updated.get('title', 'New Chat')
|
||||
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
chat_item.current_message_id = self.get_current_message_id(updated)
|
||||
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
|
@ -713,7 +828,12 @@ class ChatTable:
|
|||
async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None:
|
||||
try:
|
||||
async with get_async_db_context() as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
chat_item = await session.get(
|
||||
Chat,
|
||||
id,
|
||||
populate_existing=True,
|
||||
with_for_update=session.bind.dialect.name == 'postgresql',
|
||||
)
|
||||
if chat_item is None:
|
||||
return None
|
||||
clean_title = self._clean_null_bytes(title)
|
||||
|
|
@ -774,11 +894,12 @@ class ChatTable:
|
|||
def merge_history(existing_history: dict | None, incoming_history: dict | None) -> dict:
|
||||
existing = (existing_history or {}).get('messages') or {}
|
||||
incoming = (incoming_history or {}).get('messages') or {}
|
||||
merged = {**existing, **incoming}
|
||||
merged = {message_id: message for message_id, message in merged.items() if isinstance(message, dict)}
|
||||
merged = {
|
||||
message_id: {**message, 'childrenIds': []}
|
||||
for message_id, message in {**existing, **incoming}.items()
|
||||
if isinstance(message, dict)
|
||||
}
|
||||
|
||||
for message in merged.values():
|
||||
message['childrenIds'] = []
|
||||
for message_id, message in merged.items():
|
||||
parent_id = message.get('parentId')
|
||||
if parent_id in merged:
|
||||
|
|
@ -826,8 +947,10 @@ class ChatTable:
|
|||
if current_id is None
|
||||
else messages.get(current_id, {}).get('childrenIds', [])
|
||||
)
|
||||
while child_ids:
|
||||
visited_ids = set()
|
||||
while child_ids and child_ids[-1] not in visited_ids:
|
||||
current_id = child_ids[-1]
|
||||
visited_ids.add(current_id)
|
||||
child_ids = messages.get(current_id, {}).get('childrenIds', [])
|
||||
history['currentId'] = current_id if current_id in messages else None
|
||||
return deleted_ids
|
||||
|
|
@ -874,26 +997,22 @@ class ChatTable:
|
|||
'role': role,
|
||||
'timestamp': message.get('timestamp') or int(time.time()),
|
||||
}
|
||||
|
||||
history['currentId'] = message_id
|
||||
history['currentId'] = message_id
|
||||
return messages[message_id]
|
||||
|
||||
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
|
||||
"""Write messages to the ``chat_message`` table so future lookups
|
||||
use the fast path. Errors are logged but never raised.
|
||||
"""
|
||||
for message_id, message in messages.items():
|
||||
if not isinstance(message, dict) or not message.get('role'):
|
||||
continue
|
||||
try:
|
||||
await ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
data=message,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
|
||||
writable = {
|
||||
message_id: message
|
||||
for message_id, message in messages.items()
|
||||
if isinstance(message, dict) and message.get('role')
|
||||
}
|
||||
try:
|
||||
await ChatMessages.upsert_messages(chat_id, user_id, writable)
|
||||
except Exception as e:
|
||||
log.warning('Backfill failed for chat %s: %s', chat_id, e)
|
||||
|
||||
async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
|
||||
"""Sync ``chat_message`` rows with the committed JSON blob.
|
||||
|
|
@ -960,12 +1079,40 @@ class ChatTable:
|
|||
return history_messages
|
||||
|
||||
async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> dict | None:
|
||||
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
|
||||
if messages_map and message_id in messages_map:
|
||||
return messages_map[message_id]
|
||||
|
||||
chat = await self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
return chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
|
||||
|
||||
async def get_message_metadata(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
metadata_key: Literal['files', 'sources', 'embeds'],
|
||||
) -> Any | None:
|
||||
"""Read one message metadata field without rebuilding the whole history."""
|
||||
async with get_async_db_context() as db:
|
||||
# Read the column directly; some stored rows cannot be validated as full ChatMessageModel objects.
|
||||
result = await db.execute(
|
||||
select(getattr(ChatMessage, metadata_key)).where(ChatMessage.id == f'{chat_id}-{message_id}')
|
||||
)
|
||||
metadata_row = result.first()
|
||||
|
||||
if metadata_row is not None:
|
||||
return metadata_row[0]
|
||||
|
||||
chat = await self.get_chat_by_id(chat_id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
message = chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
|
||||
return message.get(metadata_key)
|
||||
|
||||
async def upsert_message_to_chat_by_id_and_message_id(
|
||||
self, id: str, message_id: str, message: dict, *, touch: bool = True
|
||||
) -> ChatModel | None:
|
||||
|
|
@ -974,13 +1121,17 @@ class ChatTable:
|
|||
if output_text:
|
||||
message['content'] = output_text
|
||||
|
||||
# Sanitize message content for null characters before upserting
|
||||
if isinstance(message.get('content'), str):
|
||||
message['content'] = sanitize_text_for_db(message['content'])
|
||||
message = self._clean_null_bytes(message)
|
||||
message_id = self._clean_null_bytes(message_id)
|
||||
|
||||
try:
|
||||
async with get_async_db_context() as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
chat_item = await session.get(
|
||||
Chat,
|
||||
id,
|
||||
populate_existing=True,
|
||||
with_for_update=session.bind.dialect.name == 'postgresql',
|
||||
)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
|
|
@ -991,10 +1142,9 @@ class ChatTable:
|
|||
history = chat.get('history', {})
|
||||
saved_message = self.upsert_message_to_history(history, message_id, message)
|
||||
chat['history'] = history
|
||||
clean_chat = self._clean_null_bytes(chat)
|
||||
chat_item.chat = clean_chat
|
||||
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
|
||||
chat_item.current_message_id = self.get_current_message_id(clean_chat)
|
||||
chat_item.chat = chat # chat is a fresh dict when the column was empty
|
||||
chat_item.title = chat.get('title', 'New Chat')
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
flag_modified(chat_item, 'chat')
|
||||
|
||||
if touch:
|
||||
|
|
@ -1022,7 +1172,12 @@ class ChatTable:
|
|||
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
|
||||
try:
|
||||
async with get_async_db_context() as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
chat_item = await session.get(
|
||||
Chat,
|
||||
id,
|
||||
populate_existing=True,
|
||||
with_for_update=session.bind.dialect.name == 'postgresql',
|
||||
)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
|
|
@ -1033,22 +1188,18 @@ class ChatTable:
|
|||
history = chat.get('history', {})
|
||||
deleted_ids = self.delete_message_from_history(history, message_id)
|
||||
if not deleted_ids:
|
||||
clean_chat = self._clean_null_bytes(chat)
|
||||
chat_item.chat = clean_chat
|
||||
chat_item.title = (
|
||||
self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
|
||||
)
|
||||
chat_item.current_message_id = self.get_current_message_id(clean_chat)
|
||||
chat_item.chat = chat
|
||||
chat_item.title = chat.get('title', 'New Chat')
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
flag_modified(chat_item, 'chat')
|
||||
await session.commit()
|
||||
return ChatModel.model_validate(chat_item)
|
||||
|
||||
messages = history.get('messages') or {}
|
||||
chat['history'] = history
|
||||
clean_chat = self._clean_null_bytes(chat)
|
||||
chat_item.chat = clean_chat
|
||||
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
|
||||
chat_item.current_message_id = self.get_current_message_id(clean_chat)
|
||||
chat_item.chat = chat
|
||||
chat_item.title = chat.get('title', 'New Chat')
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
flag_modified(chat_item, 'chat')
|
||||
chat_item.updated_at = int(time.time())
|
||||
await session.commit()
|
||||
|
|
@ -1066,8 +1217,14 @@ class ChatTable:
|
|||
self, id: str, message_id: str, status: dict
|
||||
) -> ChatModel | None:
|
||||
try:
|
||||
status = self._clean_null_bytes(status)
|
||||
async with get_async_db_context() as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
chat_item = await session.get(
|
||||
Chat,
|
||||
id,
|
||||
populate_existing=True,
|
||||
with_for_update=session.bind.dialect.name == 'postgresql',
|
||||
)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
|
|
@ -1082,10 +1239,9 @@ class ChatTable:
|
|||
history['messages'][message_id]['statusHistory'] = status_history
|
||||
|
||||
chat['history'] = history
|
||||
clean_chat = self._clean_null_bytes(chat)
|
||||
chat_item.chat = clean_chat
|
||||
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
|
||||
chat_item.current_message_id = self.get_current_message_id(clean_chat)
|
||||
chat_item.chat = chat
|
||||
chat_item.title = chat.get('title', 'New Chat')
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
flag_modified(chat_item, 'chat')
|
||||
await session.commit()
|
||||
|
||||
|
|
@ -1093,13 +1249,20 @@ class ChatTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]:
|
||||
async def add_message_files_by_id_and_message_id(
|
||||
self, id: str, message_id: str, files: list[dict]
|
||||
) -> list[dict] | None:
|
||||
async with get_async_db_context() as session:
|
||||
chat = await self.get_chat_by_id(id, db=session)
|
||||
if chat is None:
|
||||
chat_item = await session.get(
|
||||
Chat,
|
||||
id,
|
||||
populate_existing=True,
|
||||
with_for_update=session.bind.dialect.name == 'postgresql',
|
||||
)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
chat = chat.chat
|
||||
chat = chat_item.chat or {}
|
||||
history = chat.get('history', {})
|
||||
|
||||
message_files = []
|
||||
|
|
@ -1109,8 +1272,14 @@ class ChatTable:
|
|||
message_files = message_files + files
|
||||
history['messages'][message_id]['files'] = message_files
|
||||
|
||||
# Written here rather than through update_chat_by_id: with session sharing off that opens a second
|
||||
# connection, which then blocks on the lock this one holds.
|
||||
chat['history'] = history
|
||||
await self.update_chat_by_id(id, chat, db=session)
|
||||
chat_item.chat = self._clean_null_bytes(chat)
|
||||
# History was mutated in place, so the new blob compares equal to the loaded one.
|
||||
flag_modified(chat_item, 'chat')
|
||||
chat_item.updated_at = int(time.time())
|
||||
await session.commit()
|
||||
return message_files
|
||||
|
||||
async def insert_shared_chat_by_chat_id(self, chat_id: str, db: AsyncSession | None = None) -> ChatModel | None:
|
||||
|
|
@ -1508,6 +1677,7 @@ class ChatTable:
|
|||
|
||||
repaired_history = self._repair_chat_current_id(chat_item.chat or {})
|
||||
if repaired_history:
|
||||
chat_item.current_message_id = self.get_current_message_id(chat_item.chat)
|
||||
flag_modified(chat_item, 'chat')
|
||||
if self._sanitize_chat_row(chat_item) or repaired_history:
|
||||
await session.commit()
|
||||
|
|
@ -1549,6 +1719,7 @@ class ChatTable:
|
|||
|
||||
repaired_history = self._repair_chat_current_id(chat.chat or {})
|
||||
if repaired_history:
|
||||
chat.current_message_id = self.get_current_message_id(chat.chat)
|
||||
flag_modified(chat, 'chat')
|
||||
if self._sanitize_chat_row(chat) or repaired_history:
|
||||
await session.commit()
|
||||
|
|
@ -1557,6 +1728,41 @@ class ChatTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_chat_by_id_for_user(
|
||||
self,
|
||||
id: str,
|
||||
user,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ChatModel | None:
|
||||
chat = await self.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if chat:
|
||||
return chat
|
||||
|
||||
chat = await self.get_chat_by_id(id, db=db)
|
||||
if not chat:
|
||||
return None
|
||||
|
||||
if user.role == 'admin' and (ENABLE_ADMIN_CHAT_ACCESS or is_internal_chat(chat.meta)):
|
||||
return chat
|
||||
|
||||
if await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
):
|
||||
return chat
|
||||
|
||||
if chat.folder_id:
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
|
||||
folder = await Folders.get_folder_by_id(chat.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
return chat
|
||||
|
||||
return None
|
||||
|
||||
async def is_chat_owner(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
|
||||
"""
|
||||
Lightweight ownership check — uses EXISTS subquery instead of loading
|
||||
|
|
@ -1732,7 +1938,7 @@ class ChatTable:
|
|||
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
|
||||
|
||||
# search user conversations
|
||||
async def get_chats_by_user_id_and_search_text(
|
||||
async def get_chats_by_user_id_and_search_text( # noqa: C901
|
||||
self,
|
||||
user_id: str,
|
||||
search_text: str,
|
||||
|
|
@ -1751,7 +1957,7 @@ class ChatTable:
|
|||
user_id, include_archived, filter={}, skip=skip, limit=limit, db=db
|
||||
)
|
||||
|
||||
search_text_words = search_text.split(' ')
|
||||
search_text_words = search_text.split()
|
||||
|
||||
# search_text might contain 'tag:tag_name' format so we need to extract the tag_name
|
||||
tag_ids = [
|
||||
|
|
@ -1783,19 +1989,10 @@ class ChatTable:
|
|||
elif 'shared:false' in search_text_words:
|
||||
is_shared = False
|
||||
|
||||
search_text_words = [
|
||||
word
|
||||
for word in search_text_words
|
||||
if (
|
||||
not word.startswith('tag:')
|
||||
and not word.startswith('folder:')
|
||||
and not word.startswith('pinned:')
|
||||
and not word.startswith('archived:')
|
||||
and not word.startswith('shared:')
|
||||
)
|
||||
]
|
||||
search_text_words = [word for word in search_text_words if not word.startswith(CHAT_SEARCH_FILTER_PREFIXES)]
|
||||
|
||||
search_text = ' '.join(search_text_words)
|
||||
phrase_query = ' '.join(search_text_words).strip()
|
||||
search_terms = chat_search_terms(phrase_query)
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(Chat).filter(Chat.user_id == user_id)
|
||||
|
|
@ -1818,27 +2015,43 @@ class ChatTable:
|
|||
if folder_ids:
|
||||
stmt = stmt.filter(Chat.folder_id.in_(folder_ids))
|
||||
|
||||
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
# Check if the database dialect is either 'sqlite' or 'postgresql'
|
||||
bind = await session.connection()
|
||||
dialect_name = bind.dialect.name
|
||||
if dialect_name == 'sqlite':
|
||||
# SQLite case: using JSON1 extension for JSON searching
|
||||
sqlite_content_sql = (
|
||||
'EXISTS ('
|
||||
' SELECT 1 '
|
||||
" FROM json_each(Chat.chat, '$.messages') AS message "
|
||||
" WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'"
|
||||
')'
|
||||
|
||||
search_params = {}
|
||||
exact_match_clause = None
|
||||
if phrase_query:
|
||||
exact_match_clause = or_(
|
||||
Chat.title.ilike(bindparam('phrase_title_key')),
|
||||
text(chat_search_message_content_match_sql(dialect_name, 'phrase_content_key')),
|
||||
)
|
||||
sqlite_content_clause = text(sqlite_content_sql)
|
||||
stmt = stmt.filter(
|
||||
or_(Chat.title.ilike(bindparam('title_key')), sqlite_content_clause).params(
|
||||
title_key=f'%{search_text}%', content_key=search_text
|
||||
)
|
||||
search_params.update(
|
||||
{
|
||||
'phrase_title_key': f'%{phrase_query}%',
|
||||
'phrase_content_key': phrase_query,
|
||||
}
|
||||
)
|
||||
|
||||
term_clauses = []
|
||||
for term_idx, term in enumerate(search_terms):
|
||||
title_key = f'term_title_key_{term_idx}'
|
||||
content_key = f'term_content_key_{term_idx}'
|
||||
term_clauses.append(
|
||||
or_(
|
||||
Chat.title.ilike(bindparam(title_key)),
|
||||
text(chat_search_message_content_match_sql(dialect_name, content_key)),
|
||||
)
|
||||
)
|
||||
search_params[title_key] = f'%{term}%'
|
||||
search_params[content_key] = term
|
||||
|
||||
if term_clauses:
|
||||
stmt = stmt.filter(or_(exact_match_clause, and_(*term_clauses)))
|
||||
else:
|
||||
stmt = stmt.filter(exact_match_clause)
|
||||
|
||||
if dialect_name == 'sqlite':
|
||||
# Check if there are any tags to filter
|
||||
if 'none' in tag_ids:
|
||||
stmt = stmt.filter(
|
||||
|
|
@ -1872,38 +2085,6 @@ class ChatTable:
|
|||
# Safety filter: title must not contain actual null bytes
|
||||
stmt = stmt.filter(text("Chat.title::text NOT LIKE '%\\x00%'"))
|
||||
|
||||
postgres_content_sql = """
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM chat_message AS message
|
||||
WHERE message.chat_id = Chat.id
|
||||
AND message.user_id = Chat.user_id
|
||||
AND json_typeof(message.content) = 'string'
|
||||
AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat#>'{history,messages}') AS history_message
|
||||
WHERE json_typeof(history_message.value->'content') = 'string'
|
||||
AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
|
||||
WHERE json_typeof(legacy_message->'content') = 'string'
|
||||
AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
"""
|
||||
|
||||
postgres_content_clause = text(postgres_content_sql)
|
||||
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
Chat.title.ilike(bindparam('title_key')),
|
||||
postgres_content_clause,
|
||||
)
|
||||
).params(title_key=f'%{search_text}%', content_key=search_text.lower())
|
||||
|
||||
if 'none' in tag_ids:
|
||||
stmt = stmt.filter(
|
||||
text("""
|
||||
|
|
@ -1931,12 +2112,20 @@ class ChatTable:
|
|||
else:
|
||||
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
|
||||
|
||||
if exact_match_clause is not None:
|
||||
stmt = stmt.order_by(case((exact_match_clause, 0), else_=1), Chat.updated_at.desc(), Chat.id)
|
||||
else:
|
||||
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
if search_params:
|
||||
stmt = stmt.params(**search_params)
|
||||
|
||||
# Perform pagination at the SQL level
|
||||
stmt = stmt.offset(skip).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
all_chats = result.scalars().all()
|
||||
|
||||
log.info(f'The number of chats: {len(all_chats)}')
|
||||
log.info('The number of chats: %s', len(all_chats))
|
||||
|
||||
# Validate and return chats
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
|
@ -2101,7 +2290,7 @@ class ChatTable:
|
|||
|
||||
bind = await session.connection()
|
||||
dialect_name = bind.dialect.name
|
||||
log.info(f'DB dialect name: {dialect_name}')
|
||||
log.info('DB dialect name: %s', dialect_name)
|
||||
if dialect_name == 'sqlite':
|
||||
stmt = stmt.filter(
|
||||
text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)")
|
||||
|
|
@ -2228,7 +2417,7 @@ class ChatTable:
|
|||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
count = result.scalar()
|
||||
|
||||
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||
log.info("Count of chats for folder '%s': %s", folder_id, count)
|
||||
return count
|
||||
|
||||
async def count_chats_by_folder_ids_and_user_id(
|
||||
|
|
@ -2242,7 +2431,7 @@ class ChatTable:
|
|||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
count = result.scalar()
|
||||
|
||||
log.info(f"Count of chats for folders '{folder_ids}': {count}")
|
||||
log.info("Count of chats for folders '%s': %s", folder_ids, count)
|
||||
return count
|
||||
|
||||
async def delete_tag_by_id_and_user_id_and_tag_name(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ DICT_CONFIG_KEY_ALIASES = {
|
|||
'audio.tts.openai.params': ('AUDIO_TTS_OPENAI_PARAMS',),
|
||||
'models.default_metadata': ('DEFAULT_MODEL_METADATA',),
|
||||
'models.default_params': ('DEFAULT_MODEL_PARAMS',),
|
||||
'task.model.params': ('TASK_MODEL_PARAMS',),
|
||||
'ui.default_interface_settings': ('DEFAULT_INTERFACE_SETTINGS',),
|
||||
'user.permissions': ('USER_PERMISSIONS',),
|
||||
}
|
||||
DICT_CONFIG_KEYS = tuple(DICT_CONFIG_KEY_ALIASES)
|
||||
|
|
@ -298,14 +300,16 @@ class Config(Base):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
async def repair_flattened_dict_configs() -> None:
|
||||
"""Reassemble dict config values flattened by the per-key migration."""
|
||||
async def repair_config_rows() -> None:
|
||||
"""Repair known legacy config row shapes."""
|
||||
if not Config.PERSISTENT_ENABLED:
|
||||
return
|
||||
|
||||
async with get_async_db() as db:
|
||||
repaired_keys: list[str] = []
|
||||
orphan_keys: list[str] = []
|
||||
default_model_keys: list[str] = []
|
||||
now = int(time.time())
|
||||
|
||||
for config_key, aliases in DICT_CONFIG_KEY_ALIASES.items():
|
||||
prefixes = (config_key, *aliases)
|
||||
|
|
@ -353,14 +357,26 @@ class Config(Base):
|
|||
|
||||
if existing:
|
||||
existing.value = repaired
|
||||
existing.updated_at = int(time.time())
|
||||
existing.updated_at = now
|
||||
else:
|
||||
db.add(Config(key=config_key, value=repaired, updated_at=int(time.time())))
|
||||
db.add(Config(key=config_key, value=repaired, updated_at=now))
|
||||
repaired_keys.append(config_key)
|
||||
|
||||
if orphan_keys:
|
||||
await db.execute(delete(Config).where(Config.key.in_(orphan_keys)))
|
||||
|
||||
if repaired_keys or orphan_keys:
|
||||
for key in ('ui.default_models', 'ui.default_pinned_models'):
|
||||
row = await db.get(Config, key)
|
||||
if not row or not isinstance(row.value, list):
|
||||
continue
|
||||
|
||||
row.value = ','.join(model_id for model_id in (str(item).strip() for item in row.value) if model_id)
|
||||
row.updated_at = now
|
||||
default_model_keys.append(key)
|
||||
|
||||
if repaired_keys or orphan_keys or default_model_keys:
|
||||
await db.commit()
|
||||
log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys))
|
||||
if repaired_keys or orphan_keys:
|
||||
log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys))
|
||||
if default_model_keys:
|
||||
log.info('Repaired default model config rows for %s', ', '.join(default_model_keys))
|
||||
|
|
|
|||
|
|
@ -157,6 +157,11 @@ class FolderTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_folders_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> list[FolderModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Folder).filter(Folder.id.in_(ids)).order_by(Folder.updated_at.desc()))
|
||||
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
|
||||
|
||||
async def get_shared_folder_ids_for_user(
|
||||
self, user_id: str, user_group_ids: set[str], db: Optional[AsyncSession] = None
|
||||
) -> dict[str, str]:
|
||||
|
|
@ -197,10 +202,14 @@ class FolderTable:
|
|||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
folders = []
|
||||
seen_ids = {id}
|
||||
|
||||
async def get_children(folder):
|
||||
children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db)
|
||||
for child in children:
|
||||
if child.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(child.id)
|
||||
await get_children(child)
|
||||
folders.append(child)
|
||||
|
||||
|
|
@ -230,7 +239,9 @@ class FolderTable:
|
|||
async with get_async_db_context(db) as db:
|
||||
# Check if folder exists
|
||||
result = await db.execute(
|
||||
select(Folder).filter_by(parent_id=parent_id, user_id=user_id).filter(Folder.name.ilike(name))
|
||||
select(Folder)
|
||||
.filter_by(parent_id=parent_id, user_id=user_id)
|
||||
.filter(func.lower(Folder.name) == func.lower(name))
|
||||
)
|
||||
folder = result.scalars().first()
|
||||
|
||||
|
|
@ -246,7 +257,9 @@ class FolderTable:
|
|||
self, parent_id: Optional[str], user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[FolderModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id))
|
||||
result = await db.execute(
|
||||
select(Folder).filter_by(parent_id=parent_id, user_id=user_id).order_by(Folder.updated_at.desc())
|
||||
)
|
||||
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
|
||||
|
||||
async def get_folder_ids_by_id_and_user_id_in_subtree(
|
||||
|
|
@ -258,15 +271,17 @@ class FolderTable:
|
|||
if not folder:
|
||||
return []
|
||||
|
||||
folder_ids = [folder.id]
|
||||
folder_ids = {folder.id}
|
||||
folders = [FolderModel.model_validate(folder)]
|
||||
while folders:
|
||||
current_folder = folders.pop()
|
||||
children = await self.get_folders_by_parent_id_and_user_id(current_folder.id, user_id, db=db)
|
||||
folder_ids.extend(child.id for child in children)
|
||||
folders.extend(children)
|
||||
for child in children:
|
||||
if child.id not in folder_ids:
|
||||
folder_ids.add(child.id)
|
||||
folders.append(child)
|
||||
|
||||
return folder_ids
|
||||
return list(folder_ids)
|
||||
|
||||
async def update_folder_parent_id_by_id_and_user_id(
|
||||
self,
|
||||
|
|
@ -376,11 +391,15 @@ class FolderTable:
|
|||
return folder_ids
|
||||
|
||||
folder_ids.append(folder.id)
|
||||
seen_ids = {folder.id}
|
||||
|
||||
# Delete all children folders
|
||||
async def delete_children(folder):
|
||||
folder_children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db)
|
||||
for folder_child in folder_children:
|
||||
if folder_child.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(folder_child.id)
|
||||
await delete_children(folder_child)
|
||||
folder_ids.append(folder_child.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -13,6 +12,7 @@ from sqlalchemy import (
|
|||
BigInteger,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
and_,
|
||||
|
|
@ -72,6 +72,8 @@ class GroupModel(BaseModel):
|
|||
|
||||
class GroupMember(Base):
|
||||
__tablename__ = 'group_member'
|
||||
# The table's (group_id, user_id) unique constraint cannot serve user_id lookups.
|
||||
__table_args__ = (Index('ix_group_member_user_id_group_id', 'user_id', 'group_id'),)
|
||||
|
||||
id = Column(Text, unique=True, primary_key=True)
|
||||
group_id = Column(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -468,14 +467,22 @@ class KnowledgeTable:
|
|||
print('search_knowledge_files error:', e)
|
||||
return KnowledgeFileListResponse(items=[], total=0)
|
||||
|
||||
async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool:
|
||||
async def check_access_by_user_id(
|
||||
self,
|
||||
id,
|
||||
user_id,
|
||||
permission='write',
|
||||
db: Optional[AsyncSession] = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> bool:
|
||||
knowledge = await self.get_knowledge_by_id(id, db=db)
|
||||
if not knowledge:
|
||||
return False
|
||||
if knowledge.user_id == user_id:
|
||||
return True
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
if user_group_ids is None:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
return await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
|
|
@ -485,24 +492,6 @@ class KnowledgeTable:
|
|||
db=db,
|
||||
)
|
||||
|
||||
async def get_knowledge_bases_by_user_id(
|
||||
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
|
||||
) -> list[KnowledgeUserModel]:
|
||||
knowledge_bases = await self.get_knowledge_bases(db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
# One grants query for all non-owned knowledge bases instead of one each
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids]
|
||||
|
||||
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
@ -512,29 +501,6 @@ class KnowledgeTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_knowledge_by_id_and_user_id(
|
||||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[KnowledgeModel]:
|
||||
knowledge = await self.get_knowledge_by_id(id, db=db)
|
||||
if not knowledge:
|
||||
return None
|
||||
|
||||
if knowledge.user_id == user_id:
|
||||
return knowledge
|
||||
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
if await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge.id,
|
||||
permission='write',
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
return knowledge
|
||||
return None
|
||||
|
||||
async def get_knowledges_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[KnowledgeModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
@ -809,25 +775,6 @@ class KnowledgeTable:
|
|||
log.exception(e)
|
||||
return None
|
||||
|
||||
async def update_knowledge_data_by_id(
|
||||
self, id: str, data: dict, db: Optional[AsyncSession] = None
|
||||
) -> Optional[KnowledgeModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(
|
||||
update(Knowledge)
|
||||
.filter_by(id=id)
|
||||
.values(
|
||||
data=data,
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return await self.get_knowledge_by_id(id=id, db=db)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
|
||||
async def update_knowledge_meta_by_id(
|
||||
self, id: str, meta: dict, db: Optional[AsyncSession] = None
|
||||
) -> Optional[KnowledgeModel]:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
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, Users
|
||||
from open_webui.utils.misc import json_text_variants
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -23,6 +22,18 @@ log = logging.getLogger(__name__)
|
|||
_warned_profile_urls: set[str] = set()
|
||||
|
||||
|
||||
def normalize_model_tags(tags: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(tags, list):
|
||||
return []
|
||||
|
||||
normalized = []
|
||||
for tag in tags:
|
||||
name = tag.get('name') if isinstance(tag, dict) else tag
|
||||
if isinstance(name, str) and name.strip():
|
||||
normalized.append({'name': name.strip()})
|
||||
return normalized
|
||||
|
||||
|
||||
def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any:
|
||||
"""Drop duplicated extracted text from ModelMeta.knowledge."""
|
||||
if not isinstance(knowledge, list):
|
||||
|
|
@ -99,15 +110,7 @@ class ModelMeta(BaseModel):
|
|||
@classmethod
|
||||
def normalize_tags(cls, data):
|
||||
if isinstance(data, dict) and 'tags' in data:
|
||||
raw_tags = data['tags']
|
||||
if isinstance(raw_tags, list):
|
||||
normalized = []
|
||||
for tag in raw_tags:
|
||||
if isinstance(tag, str):
|
||||
normalized.append({'name': tag})
|
||||
elif isinstance(tag, dict) and 'name' in tag:
|
||||
normalized.append(tag)
|
||||
data['tags'] = normalized
|
||||
data['tags'] = normalize_model_tags(data['tags'])
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -244,9 +247,21 @@ class ModelsTable:
|
|||
log.error('Skipping model %r during get_all_models due to error: %s', model.id, exc)
|
||||
return models
|
||||
|
||||
async def get_models(self, db: AsyncSession | None = None) -> list[ModelUserResponse]:
|
||||
async def get_models(
|
||||
self, writable_by_user_id: str | None = None, db: AsyncSession | None = None
|
||||
) -> list[ModelUserResponse]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Model).filter(Model.base_model_id != None))
|
||||
stmt = select(Model).filter(Model.base_model_id != None)
|
||||
|
||||
if writable_by_user_id:
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(writable_by_user_id, db=db)
|
||||
}
|
||||
stmt = self._has_permission(
|
||||
db, stmt, {'user_id': writable_by_user_id, 'group_ids': user_group_ids}, permission='write'
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_models = result.scalars().all()
|
||||
|
||||
user_ids = list(set(model.user_id for model in all_models))
|
||||
|
|
@ -275,6 +290,24 @@ class ModelsTable:
|
|||
)
|
||||
return models
|
||||
|
||||
async def get_model_owners_attaching_file(self, file_id: str, db: AsyncSession | None = None) -> dict[str, str]:
|
||||
"""Map of model id to owner id for workspace models whose knowledge attaches this file."""
|
||||
async with get_async_db_context(db) as db:
|
||||
# File ids are server-generated uuids, so the text match can only over-match.
|
||||
result = await db.execute(
|
||||
select(Model.id, Model.user_id, Model.meta).filter(
|
||||
Model.base_model_id.is_not(None), cast(Model.meta, String).like(f'%"{file_id}"%')
|
||||
)
|
||||
)
|
||||
return {
|
||||
model_id: user_id
|
||||
for model_id, user_id, meta in result.all()
|
||||
if any(
|
||||
isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file_id
|
||||
for item in meta.get('knowledge') or []
|
||||
)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _meta_has_tag(meta: dict | None, tag: str) -> bool:
|
||||
if not meta:
|
||||
|
|
@ -301,28 +334,6 @@ class ModelsTable:
|
|||
for model in all_models
|
||||
]
|
||||
|
||||
async def get_models_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = 'write',
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> list[ModelUserResponse]:
|
||||
models = await self.get_models(db=db)
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
|
||||
# One grants query for all non-owned models instead of one per model
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_ids=[model.id for model in models if model.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [model for model in models if model.user_id == user_id or model.id in accessible_ids]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
|
||||
return AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
|
|
@ -374,20 +385,14 @@ class ModelsTable:
|
|||
|
||||
tag = filter.get('tag')
|
||||
if tag:
|
||||
# SQLite stores JSON text via json.dumps(ensure_ascii=True),
|
||||
# so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB
|
||||
# stores literal Unicode. Use the right pattern for each.
|
||||
if db.bind.dialect.name == 'sqlite':
|
||||
if tag.isascii():
|
||||
meta_text = func.lower(cast(Model.meta, String))
|
||||
pattern = f'%{json.dumps(tag.lower())}%'
|
||||
else:
|
||||
meta_text = cast(Model.meta, String)
|
||||
pattern = f'%{json.dumps(tag)}%'
|
||||
if db.bind.dialect.name == 'sqlite' and not tag.isascii():
|
||||
# SQLite's LOWER() is ASCII-only, so match non-ASCII tags exact-case.
|
||||
meta_text = cast(Model.meta, String)
|
||||
variants = json_text_variants(tag)
|
||||
else:
|
||||
meta_text = func.lower(cast(Model.meta, String))
|
||||
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
|
||||
stmt = stmt.filter(meta_text.like(pattern))
|
||||
variants = json_text_variants(tag.lower())
|
||||
stmt = stmt.filter(or_(*(meta_text.like(f'%"{variant}"%') for variant in variants)))
|
||||
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
|
|
@ -605,25 +610,16 @@ class ModelsTable:
|
|||
|
||||
# Update or insert models
|
||||
for model in models:
|
||||
model_data = {
|
||||
**model.model_dump(exclude={'access_grants'}),
|
||||
'user_id': user_id,
|
||||
'updated_at': int(time.time()),
|
||||
}
|
||||
|
||||
if model.id in existing_ids:
|
||||
await db.execute(
|
||||
update(Model)
|
||||
.filter_by(id=model.id)
|
||||
.values(
|
||||
**model.model_dump(exclude={'access_grants'}),
|
||||
user_id=user_id,
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
await db.execute(update(Model).filter_by(id=model.id).values(**model_data))
|
||||
else:
|
||||
new_model = Model(
|
||||
**{
|
||||
**model.model_dump(exclude={'access_grants'}),
|
||||
'user_id': user_id,
|
||||
'updated_at': int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(new_model)
|
||||
db.add(Model(**model_data))
|
||||
await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db)
|
||||
|
||||
# Remove models that are no longer present
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import time
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
|
|
@ -8,7 +7,8 @@ from open_webui.internal.db import Base, get_async_db_context
|
|||
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, Users
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, ForeignKey, Text, delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -31,6 +31,32 @@ class Note(Base):
|
|||
updated_at = Column(BigInteger)
|
||||
|
||||
|
||||
def sanitize_note_data(data: Optional[dict]) -> Optional[dict]:
|
||||
"""Sanitize malformed note.data so content.md is always markdown text."""
|
||||
if data is None:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return {'content': {'md': str(data)}}
|
||||
|
||||
content = data.get('content')
|
||||
if not isinstance(content, dict) or 'md' not in content or isinstance(content.get('md'), str):
|
||||
return data
|
||||
|
||||
md = content.get('md') if content.get('md') is not None else ''
|
||||
if isinstance(md, (dict, list)):
|
||||
md = f'```json\n{JSONCodec.dumps(md, indent=2, ensure_ascii=False)}\n```'
|
||||
else:
|
||||
md = str(md)
|
||||
|
||||
return {
|
||||
**data,
|
||||
'content': {
|
||||
**content,
|
||||
'md': md,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class NoteModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
@ -47,6 +73,11 @@ class NoteModel(BaseModel):
|
|||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
@field_validator('data', mode='before')
|
||||
@classmethod
|
||||
def sanitize_data(cls, data):
|
||||
return sanitize_note_data(data)
|
||||
|
||||
|
||||
class PinnedNote(Base):
|
||||
__tablename__ = 'pinned_note'
|
||||
|
|
@ -68,6 +99,11 @@ class NoteForm(BaseModel):
|
|||
meta: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
@field_validator('data', mode='before')
|
||||
@classmethod
|
||||
def sanitize_data(cls, data):
|
||||
return sanitize_note_data(data)
|
||||
|
||||
|
||||
class NoteUpdateForm(BaseModel):
|
||||
title: Optional[str] = None
|
||||
|
|
@ -75,6 +111,11 @@ class NoteUpdateForm(BaseModel):
|
|||
meta: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
@field_validator('data', mode='before')
|
||||
@classmethod
|
||||
def sanitize_data(cls, data):
|
||||
return sanitize_note_data(data)
|
||||
|
||||
|
||||
class NoteUserResponse(NoteModel):
|
||||
user: Optional[UserResponse] = None
|
||||
|
|
@ -305,6 +346,7 @@ class NoteTable:
|
|||
return None
|
||||
|
||||
form_data = form_data.model_dump(exclude_unset=True)
|
||||
note.data = sanitize_note_data(note.data) or {}
|
||||
|
||||
if 'title' in form_data:
|
||||
note.title = form_data['title']
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -9,6 +8,7 @@ from typing import List, Optional
|
|||
from cryptography.fernet import Fernet
|
||||
from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Index, String, Text, delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -85,7 +85,7 @@ class OAuthSessionTable:
|
|||
def _encrypt_token(self, token) -> str:
|
||||
"""Encrypt OAuth tokens for storage"""
|
||||
try:
|
||||
token_json = json.dumps(token)
|
||||
token_json = JSONCodec.dumps(token)
|
||||
encrypted = self.fernet.encrypt(token_json.encode()).decode()
|
||||
return encrypted
|
||||
except Exception as e:
|
||||
|
|
@ -96,7 +96,7 @@ class OAuthSessionTable:
|
|||
"""Decrypt OAuth tokens from storage"""
|
||||
try:
|
||||
decrypted = self.fernet.decrypt(token.encode()).decode()
|
||||
return json.loads(decrypted)
|
||||
return JSONCodec.loads(decrypted)
|
||||
except Exception as e:
|
||||
log.error(f'Error decrypting tokens: {type(e).__name__}: {e}')
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""Prompt history model for version tracking."""
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -15,6 +14,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
|||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.prompt_history import PromptHistories
|
||||
from open_webui.models.users import User, UserModel, UserResponse, Users
|
||||
from open_webui.utils.misc import json_text_variants
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -342,9 +342,10 @@ class PromptsTable:
|
|||
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
|
||||
)
|
||||
else:
|
||||
# Fallback: LIKE on serialised JSON text (ASCII-safe only)
|
||||
tag_clause = func.lower(cast(Prompt.tags, String)).like(
|
||||
f'%{json.dumps(tag_lower, ensure_ascii=False)}%'
|
||||
# Fallback for dialects with no JSON array function: LIKE on the text.
|
||||
tags_text = func.lower(cast(Prompt.tags, String))
|
||||
tag_clause = or_(
|
||||
*(tags_text.like(f'%"{variant}"%') for variant in json_text_variants(tag_lower))
|
||||
)
|
||||
tag_lower = None
|
||||
|
||||
|
|
|
|||
|
|
@ -163,9 +163,30 @@ class SkillsTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_skills(self, db: Optional[AsyncSession] = None) -> list[SkillUserModel]:
|
||||
async def get_skills(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
ids: list[str] | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[SkillUserModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Skill).order_by(Skill.updated_at.desc()))
|
||||
stmt = select(Skill).order_by(Skill.updated_at.desc())
|
||||
|
||||
if ids is not None:
|
||||
stmt = stmt.filter(Skill.id.in_(ids))
|
||||
|
||||
if user_id is not None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
stmt = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=stmt,
|
||||
DocumentModel=Skill,
|
||||
filter={'user_id': user_id, 'group_ids': user_group_ids},
|
||||
resource_type='skill',
|
||||
permission='read',
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_skills = result.scalars().all()
|
||||
|
||||
user_ids = list(set(skill.user_id for skill in all_skills))
|
||||
|
|
@ -194,28 +215,6 @@ class SkillsTable:
|
|||
)
|
||||
return skills
|
||||
|
||||
async def get_skills_by_user_id(
|
||||
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
|
||||
) -> list[SkillUserModel]:
|
||||
skills = await self.get_skills(db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
result = []
|
||||
for skill in skills:
|
||||
if skill.user_id == user_id:
|
||||
result.append(skill)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='skill',
|
||||
resource_id=skill.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(skill)
|
||||
return result
|
||||
|
||||
async def search_skills(
|
||||
self,
|
||||
user_id: str,
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class TagTable:
|
|||
async with get_async_db_context(db) as db:
|
||||
id = name.replace(' ', '_').lower()
|
||||
result = await db.execute(delete(Tag).filter_by(id=id, user_id=user_id))
|
||||
log.debug(f'res: {result.rowcount}')
|
||||
log.debug('res: %s', result.rowcount)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -169,20 +169,37 @@ class ToolsTable:
|
|||
for tool in tools
|
||||
}
|
||||
|
||||
async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]:
|
||||
async def get_tools(
|
||||
self,
|
||||
defer_content: bool = False,
|
||||
db: AsyncSession | None = None,
|
||||
user_id: str | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
permission: str = 'read',
|
||||
) -> list[ToolUserModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
if defer_content:
|
||||
# Skip Tool.content (plugin source, potentially large) via a
|
||||
# column select; Row attributes satisfy from_attributes.
|
||||
result = await db.execute(
|
||||
select(
|
||||
Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at
|
||||
).order_by(Tool.updated_at.desc())
|
||||
# Skip Tool.content (plugin source, potentially large) via a
|
||||
# column select; Row attributes satisfy from_attributes.
|
||||
stmt = (
|
||||
select(Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at)
|
||||
if defer_content
|
||||
else select(Tool)
|
||||
).order_by(Tool.updated_at.desc())
|
||||
|
||||
if user_id is not None:
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
stmt = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=stmt,
|
||||
DocumentModel=Tool,
|
||||
filter={'user_id': user_id, 'group_ids': user_group_ids},
|
||||
resource_type='tool',
|
||||
permission=permission,
|
||||
)
|
||||
all_tools = result.all()
|
||||
else:
|
||||
result = await db.execute(select(Tool).order_by(Tool.updated_at.desc()))
|
||||
all_tools = result.scalars().all()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_tools = result.all() if defer_content else result.scalars().all()
|
||||
|
||||
user_ids = list(set(tool.user_id for tool in all_tools))
|
||||
tool_ids = [tool.id for tool in all_tools]
|
||||
|
|
@ -217,20 +234,15 @@ class ToolsTable:
|
|||
defer_content: bool = False,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[ToolUserModel]:
|
||||
tools = await self.get_tools(defer_content=defer_content, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
# One grants query for all non-owned tools instead of one per tool
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='tool',
|
||||
resource_ids=[tool.id for tool in tools if tool.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
return await self.get_tools(
|
||||
defer_content=defer_content,
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
user_group_ids=user_group_ids,
|
||||
permission=permission,
|
||||
)
|
||||
return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids]
|
||||
|
||||
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ from sqlalchemy import (
|
|||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
####################
|
||||
|
|
@ -360,16 +359,15 @@ class UsersTable:
|
|||
sub: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Look up a user by OAuth provider + subject claim (dialect-aware JSON filter)."""
|
||||
"""Look up a user by OAuth provider + subject claim."""
|
||||
sub = str(sub)
|
||||
async with get_async_db_context(db) as session:
|
||||
dialect = session.bind.dialect.name
|
||||
query = select(User)
|
||||
if dialect == 'sqlite':
|
||||
oauth_match = User.oauth.contains({provider: {'sub': sub}})
|
||||
query = query.where(oauth_match)
|
||||
elif dialect == 'postgresql':
|
||||
oauth_match = User.oauth[provider].cast(JSONB)['sub'].astext == sub
|
||||
query = query.where(oauth_match)
|
||||
# Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
|
||||
sub_expr = User.oauth[provider]['sub'].as_string()
|
||||
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)))
|
||||
row = (await session.execute(query)).scalars().first()
|
||||
return UserModel.model_validate(row) if row else None
|
||||
|
||||
|
|
@ -379,27 +377,76 @@ class UsersTable:
|
|||
external_id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Look up a user by SCIM provider + external ID (dialect-aware JSON filter)."""
|
||||
"""Look up a user by SCIM provider + external ID."""
|
||||
async with get_async_db_context(db) as session:
|
||||
dialect = session.bind.dialect.name
|
||||
query = select(User)
|
||||
if dialect == 'sqlite':
|
||||
scim_match = User.scim.contains({provider: {'external_id': external_id}})
|
||||
query = query.where(scim_match)
|
||||
elif dialect == 'postgresql':
|
||||
scim_match = User.scim[provider].cast(JSONB)['external_id'].astext == external_id
|
||||
query = query.where(scim_match)
|
||||
# Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
|
||||
query = select(User).where(User.scim[provider]['external_id'].as_string() == external_id)
|
||||
row = (await session.execute(query)).scalars().first()
|
||||
return UserModel.model_validate(row) if row else None
|
||||
|
||||
async def get_users(
|
||||
async def get_scim_users(
|
||||
self,
|
||||
filter: dict | None = None,
|
||||
sort: dict | None = None,
|
||||
skip: int | None = None,
|
||||
limit: int | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict:
|
||||
"""Paginated user listing with optional filters for role, group, and channel."""
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(User).where(or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null'))
|
||||
|
||||
if filter:
|
||||
user_id = filter.get('id')
|
||||
if user_id:
|
||||
stmt = stmt.where(User.id == user_id)
|
||||
|
||||
email = filter.get('email')
|
||||
if email:
|
||||
stmt = stmt.where(func.lower(User.email) == email.lower())
|
||||
|
||||
order_by = sort.get('order_by') if sort else None
|
||||
direction = sort.get('direction') if sort else None
|
||||
|
||||
if order_by == 'created_at':
|
||||
stmt = stmt.order_by(User.created_at.asc() if direction == 'asc' else User.created_at.desc())
|
||||
|
||||
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
if skip is not None:
|
||||
stmt = stmt.offset(skip)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {
|
||||
'users': [UserModel.model_validate(user) for user in users],
|
||||
'total': total,
|
||||
}
|
||||
|
||||
async def get_scim_user_by_id(
|
||||
self,
|
||||
id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(User).where(
|
||||
User.id == id,
|
||||
or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null'),
|
||||
)
|
||||
user = (await session.execute(stmt)).scalars().first()
|
||||
return UserModel.model_validate(user) if user else None
|
||||
|
||||
async def get_users(
|
||||
self,
|
||||
filter: dict | None = None,
|
||||
sort: dict | None = None,
|
||||
skip: int | None = None,
|
||||
limit: int | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict:
|
||||
"""Paginated user listing with optional filters and sort."""
|
||||
async with get_async_db_context(db) as session:
|
||||
# Deferred imports to avoid circular dependencies
|
||||
from open_webui.models.channels import ChannelMember
|
||||
|
|
@ -460,64 +507,63 @@ class UsersTable:
|
|||
if exclude_roles:
|
||||
stmt = stmt.filter(~User.role.in_(exclude_roles))
|
||||
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
order_by = sort.get('order_by') if sort else None
|
||||
direction = sort.get('direction') if sort else None
|
||||
|
||||
if order_by and order_by.startswith('group_id:'):
|
||||
group_id = order_by.split(':', 1)[1]
|
||||
if order_by and order_by.startswith('group_id:'):
|
||||
group_id = order_by.split(':', 1)[1]
|
||||
|
||||
# Subquery that checks if the user belongs to the group
|
||||
membership_exists = exists(
|
||||
select(GroupMember.id).where(
|
||||
GroupMember.user_id == User.id,
|
||||
GroupMember.group_id == group_id,
|
||||
)
|
||||
# Subquery that checks if the user belongs to the group
|
||||
membership_exists = exists(
|
||||
select(GroupMember.id).where(
|
||||
GroupMember.user_id == User.id,
|
||||
GroupMember.group_id == group_id,
|
||||
)
|
||||
)
|
||||
|
||||
# CASE: user in group → 1, user not in group → 0
|
||||
group_sort = case((membership_exists, 1), else_=0)
|
||||
# CASE: user in group → 1, user not in group → 0
|
||||
group_sort = case((membership_exists, 1), else_=0)
|
||||
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(group_sort.asc(), User.name.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(group_sort.desc(), User.name.asc())
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(group_sort.asc(), User.name.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(group_sort.desc(), User.name.asc())
|
||||
|
||||
elif order_by == 'name':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.name.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.name.desc())
|
||||
elif order_by == 'name':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.name.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.name.desc())
|
||||
|
||||
elif order_by == 'email':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.email.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.email.desc())
|
||||
elif order_by == 'email':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.email.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.email.desc())
|
||||
|
||||
elif order_by == 'created_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.created_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.created_at.desc())
|
||||
elif order_by == 'created_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.created_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.created_at.desc())
|
||||
|
||||
elif order_by == 'last_active_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.last_active_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.last_active_at.desc())
|
||||
elif order_by == 'last_active_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.last_active_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.last_active_at.desc())
|
||||
|
||||
elif order_by == 'updated_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.updated_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.updated_at.desc())
|
||||
elif order_by == 'role':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.role.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.role.desc())
|
||||
|
||||
else:
|
||||
elif order_by == 'updated_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.updated_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.updated_at.desc())
|
||||
elif order_by == 'role':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(User.role.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(User.role.desc())
|
||||
elif not filter:
|
||||
stmt = stmt.order_by(User.created_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
|
|
@ -636,7 +682,10 @@ class UsersTable:
|
|||
if not user:
|
||||
return None
|
||||
oauth = dict(user.oauth or {})
|
||||
oauth[provider] = {'sub': sub}
|
||||
provider_oauth = oauth.get(provider)
|
||||
provider_oauth = dict(provider_oauth) if isinstance(provider_oauth, dict) else {}
|
||||
provider_oauth['sub'] = str(sub)
|
||||
oauth[provider] = provider_oauth
|
||||
user.oauth = oauth
|
||||
await session.commit()
|
||||
return UserModel.model_validate(user)
|
||||
|
|
@ -645,7 +694,7 @@ class UsersTable:
|
|||
self,
|
||||
id: str,
|
||||
provider: str,
|
||||
external_id: str,
|
||||
external_id: str | None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Update or insert a SCIM provider/external_id pair into the user's scim JSON field."""
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import List, Optional
|
|||
import requests
|
||||
from fastapi import HTTPException, status
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -64,25 +65,6 @@ class DatalabMarkerLoader:
|
|||
}
|
||||
return mime_map.get(ext, 'application/octet-stream')
|
||||
|
||||
def check_marker_request_status(self, request_id: str) -> dict:
|
||||
url = f'{self.api_base_url}/{request_id}'
|
||||
headers = {'X-Api-Key': self.api_key}
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
log.info(f'Marker API status check for request {request_id}: {result}')
|
||||
return result
|
||||
except requests.HTTPError as e:
|
||||
log.error(f'Error checking Marker request status: {e}')
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f'Failed to check Marker request: {e}',
|
||||
)
|
||||
except ValueError as e:
|
||||
log.error(f'Invalid JSON checking Marker request: {e}')
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=f'Invalid JSON: {e}')
|
||||
|
||||
def load(self) -> List[Document]:
|
||||
filename = os.path.basename(self.file_path)
|
||||
mime_type = self._get_mime_type(filename)
|
||||
|
|
@ -103,7 +85,10 @@ class DatalabMarkerLoader:
|
|||
form_data['additional_config'] = self.additional_config
|
||||
|
||||
log.info(
|
||||
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
|
||||
"Datalab Marker POST request parameters: {'filename': '%s', 'mime_type': '%s', **%s}",
|
||||
filename,
|
||||
mime_type,
|
||||
form_data,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -167,7 +152,7 @@ class DatalabMarkerLoader:
|
|||
'total_cost',
|
||||
)
|
||||
}
|
||||
log.info(f'Marker processing completed successfully: {json.dumps(summary, indent=2)}')
|
||||
log.info('Marker processing completed successfully: %s', json.dumps(summary, indent=2))
|
||||
break
|
||||
|
||||
if status_val == 'failed' or success_val is False:
|
||||
|
|
@ -234,7 +219,7 @@ class DatalabMarkerLoader:
|
|||
try:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_text)
|
||||
log.info(f'Saved Marker output to: {output_path}')
|
||||
log.info('Saved Marker output to: %s', output_path)
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to write marker output to disk: {e}')
|
||||
|
||||
|
|
@ -249,11 +234,11 @@ class DatalabMarkerLoader:
|
|||
images = final_result.get('images', {})
|
||||
if images:
|
||||
metadata['image_count'] = len(images)
|
||||
metadata['images'] = json.dumps(list(images.keys()))
|
||||
metadata['images'] = JSONCodec.dumps(list(images.keys()))
|
||||
|
||||
for k, v in metadata.items():
|
||||
if isinstance(v, (dict, list)):
|
||||
metadata[k] = json.dumps(v)
|
||||
metadata[k] = JSONCodec.dumps(v)
|
||||
elif v is None:
|
||||
metadata[k] = ''
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ class ExternalWebLoader(BaseLoader):
|
|||
response = requests.post(
|
||||
self.external_url,
|
||||
headers={
|
||||
# LICENSE covers this Open WebUI user-agent identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) External Web Loader',
|
||||
'Authorization': f'Bearer {self.external_api_key}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import asyncio
|
||||
import json
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import ftfy
|
||||
import requests
|
||||
|
|
@ -13,7 +15,6 @@ from langchain_community.document_loaders import (
|
|||
Docx2txtLoader,
|
||||
PyPDFLoader,
|
||||
TextLoader,
|
||||
YoutubeLoader,
|
||||
)
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import (
|
||||
|
|
@ -28,6 +29,7 @@ from open_webui.retrieval.loaders.mineru import MinerULoader
|
|||
from open_webui.retrieval.loaders.mistral import MistralLoader
|
||||
from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader
|
||||
from open_webui.utils.headers import get_user_groups_for_custom_headers
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -88,6 +90,15 @@ known_source_ext = [
|
|||
'toml',
|
||||
]
|
||||
|
||||
known_archive_ext = {'docx', 'epub', 'odt', 'pptx', 'xlsx'}
|
||||
known_archive_content_types = {
|
||||
'application/epub+zip',
|
||||
'application/vnd.oasis.opendocument.text',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}
|
||||
|
||||
|
||||
class ExcelLoader:
|
||||
"""Fallback Excel loader using pandas when unstructured is not installed."""
|
||||
|
|
@ -111,6 +122,52 @@ class ExcelLoader:
|
|||
]
|
||||
|
||||
|
||||
def get_csv_summary(filename: str, file_path: str, encoding: str) -> str | None:
|
||||
try:
|
||||
with open(file_path, newline='', encoding=encoding) as f:
|
||||
sample = f.read(4096)
|
||||
f.seek(0)
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(sample)
|
||||
except csv.Error:
|
||||
dialect = csv.excel
|
||||
|
||||
total_rows = 0
|
||||
max_columns = 0
|
||||
headers = []
|
||||
for row in csv.reader(f, dialect):
|
||||
total_rows += 1
|
||||
max_columns = max(max_columns, len(row))
|
||||
if total_rows == 1:
|
||||
headers = [header.lstrip('\ufeff') for header in row]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if total_rows == 0:
|
||||
return None
|
||||
|
||||
return (
|
||||
f'Table: {total_rows} rows incl. header; '
|
||||
f'{max(total_rows - 1, 0)} data rows; '
|
||||
f'{max_columns} columns: {", ".join(headers)}.'
|
||||
)
|
||||
|
||||
|
||||
class CSVLoaderWithSummary:
|
||||
def __init__(self, file_path: str, filename: str, encoding: str):
|
||||
self.file_path = file_path
|
||||
self.filename = filename
|
||||
self.encoding = encoding
|
||||
|
||||
def load(self) -> list[Document]:
|
||||
docs = CSVLoader(self.file_path, encoding=self.encoding).load()
|
||||
if os.getenv('ENABLE_RAG_CSV_SUMMARY', 'False').lower() == 'true':
|
||||
summary = get_csv_summary(self.filename, self.file_path, self.encoding)
|
||||
if summary:
|
||||
docs.insert(0, Document(page_content=summary, metadata={'source': self.file_path, 'row': -1}))
|
||||
return docs
|
||||
|
||||
|
||||
class PptxLoader:
|
||||
"""Fallback PowerPoint loader using python-pptx when unstructured is not installed."""
|
||||
|
||||
|
|
@ -138,10 +195,11 @@ class PptxLoader:
|
|||
|
||||
|
||||
class TikaLoader:
|
||||
def __init__(self, url, file_path, mime_type=None, extract_images=None):
|
||||
def __init__(self, url, file_path, mime_type=None, extract_images=None, server_version='3'):
|
||||
self.url = url
|
||||
self.file_path = file_path
|
||||
self.mime_type = mime_type
|
||||
self.server_version = str(server_version or '3')
|
||||
|
||||
self.extract_images = extract_images
|
||||
|
||||
|
|
@ -157,16 +215,15 @@ class TikaLoader:
|
|||
if self.extract_images == True:
|
||||
headers['X-Tika-PDFextractInlineImages'] = 'true'
|
||||
|
||||
endpoint = self.url
|
||||
if not endpoint.endswith('/'):
|
||||
endpoint += '/'
|
||||
endpoint += 'tika/text'
|
||||
endpoint_path = 'tika/json/text' if self.server_version == '4' else 'tika/text'
|
||||
content_key = 'tk:content' if self.server_version == '4' else 'X-TIKA:content'
|
||||
endpoint = f'{self.url.rstrip("/")}/{endpoint_path}'
|
||||
|
||||
r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY)
|
||||
|
||||
if r.ok:
|
||||
raw_metadata = r.json()
|
||||
text = raw_metadata.get('X-TIKA:content', '<No text content found>').strip()
|
||||
text = raw_metadata.get(content_key, '<No text content found>').strip()
|
||||
|
||||
if 'Content-Type' in raw_metadata:
|
||||
headers['Content-Type'] = raw_metadata['Content-Type']
|
||||
|
|
@ -429,6 +486,27 @@ class Loader:
|
|||
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
||||
file_ext = filename.split('.')[-1].lower()
|
||||
|
||||
if file_ext in known_archive_ext or file_content_type in known_archive_content_types:
|
||||
max_file_size = self.kwargs.get('FILE_MAX_SIZE')
|
||||
try:
|
||||
max_file_size_bytes = int(max_file_size) * 1024 * 1024 if max_file_size else 100 * 1024 * 1024
|
||||
except (TypeError, ValueError):
|
||||
max_file_size_bytes = 100 * 1024 * 1024
|
||||
|
||||
if max_file_size_bytes > 0:
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as archive:
|
||||
uncompressed_size = sum(entry.file_size for entry in archive.infolist())
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
pass
|
||||
else:
|
||||
max_bytes = min(
|
||||
max(10 * 1024 * 1024, os.path.getsize(file_path) * 100),
|
||||
max_file_size_bytes,
|
||||
)
|
||||
if uncompressed_size > max_bytes:
|
||||
raise ValueError('Document archive is too large after decompression')
|
||||
|
||||
if (
|
||||
self.engine == 'external'
|
||||
and self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_URL')
|
||||
|
|
@ -455,6 +533,7 @@ class Loader:
|
|||
loader = TikaLoader(
|
||||
url=self.kwargs.get('TIKA_SERVER_URL'),
|
||||
file_path=file_path,
|
||||
server_version=self.kwargs.get('TIKA_SERVER_VERSION'),
|
||||
extract_images=self.kwargs.get('PDF_EXTRACT_IMAGES'),
|
||||
)
|
||||
elif (
|
||||
|
|
@ -508,8 +587,8 @@ class Loader:
|
|||
params = self.kwargs.get('DOCLING_PARAMS', {})
|
||||
if not isinstance(params, dict):
|
||||
try:
|
||||
params = json.loads(params)
|
||||
except json.JSONDecodeError:
|
||||
params = JSONCodec.loads(params)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
log.error('Invalid DOCLING_PARAMS format, expected JSON object')
|
||||
params = {}
|
||||
|
||||
|
|
@ -594,7 +673,11 @@ class Loader:
|
|||
mode=self.kwargs.get('PDF_LOADER_MODE', 'page'),
|
||||
)
|
||||
elif file_ext == 'csv':
|
||||
loader = CSVLoader(file_path, encoding=self._detect_text_encoding(file_path))
|
||||
loader = CSVLoaderWithSummary(
|
||||
file_path,
|
||||
filename,
|
||||
self._detect_text_encoding(file_path),
|
||||
)
|
||||
elif file_ext == 'rst':
|
||||
try:
|
||||
from langchain_community.document_loaders import UnstructuredRSTLoader
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from langchain_core.documents import Document
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3'
|
||||
MICROSOFT_BROWSE_RETRY_STATUS_CODES = {202, 429, 500, 502, 503, 504}
|
||||
MICROSOFT_BROWSE_MAX_RETRIES = 2
|
||||
|
||||
|
|
@ -27,7 +26,7 @@ class MicrosoftWebIQLoader(BaseLoader):
|
|||
continue_on_failure: bool = True,
|
||||
) -> None:
|
||||
self.urls = urls if isinstance(urls, list) else [urls]
|
||||
self.api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/')
|
||||
self.api_base_url = api_base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.language = language
|
||||
self.verify_ssl = verify_ssl
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class MinerULoader:
|
|||
Load document using Local API (synchronous).
|
||||
Posts file to /file_parse endpoint and gets immediate response.
|
||||
"""
|
||||
log.info(f'Using MinerU Local API at {self.api_url}')
|
||||
log.info('Using MinerU Local API at %s', self.api_url)
|
||||
|
||||
filename = os.path.basename(self.file_path)
|
||||
|
||||
|
|
@ -97,8 +97,8 @@ class MinerULoader:
|
|||
with open(self.file_path, 'rb') as f:
|
||||
files = {'files': (filename, f, 'application/octet-stream')}
|
||||
|
||||
log.info(f'Sending file to MinerU Local API: {filename}')
|
||||
log.debug(f'Local API parameters: {form_data}')
|
||||
log.info('Sending file to MinerU Local API: %s', filename)
|
||||
log.debug('Local API parameters: %s', form_data)
|
||||
|
||||
response = requests.post(
|
||||
f'{self.api_url}/file_parse',
|
||||
|
|
@ -163,7 +163,7 @@ class MinerULoader:
|
|||
detail='MinerU returned empty markdown content',
|
||||
)
|
||||
|
||||
log.info(f'Successfully parsed document with MinerU Local API: {filename}')
|
||||
log.info('Successfully parsed document with MinerU Local API: %s', filename)
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
|
|
@ -180,7 +180,7 @@ class MinerULoader:
|
|||
Load document using Cloud API (asynchronous).
|
||||
Uses batch upload endpoint to avoid need for public file URLs.
|
||||
"""
|
||||
log.info(f'Using MinerU Cloud API at {self.api_url}')
|
||||
log.info('Using MinerU Cloud API at %s', self.api_url)
|
||||
|
||||
filename = os.path.basename(self.file_path)
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ class MinerULoader:
|
|||
# Step 4: Download and extract markdown from ZIP
|
||||
markdown_content = self._download_and_extract_zip(result['full_zip_url'], filename)
|
||||
|
||||
log.info(f'Successfully parsed document with MinerU Cloud API: {filename}')
|
||||
log.info('Successfully parsed document with MinerU Cloud API: %s', filename)
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
|
|
@ -232,8 +232,8 @@ class MinerULoader:
|
|||
if self.page_ranges:
|
||||
request_body['files'][0]['page_ranges'] = self.page_ranges
|
||||
|
||||
log.info(f'Requesting upload URL for: {filename}')
|
||||
log.debug(f'Cloud API request body: {request_body}')
|
||||
log.info('Requesting upload URL for: %s', filename)
|
||||
log.debug('Cloud API request body: %s', request_body)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
|
|
@ -284,7 +284,7 @@ class MinerULoader:
|
|||
)
|
||||
|
||||
upload_url = file_urls[0]
|
||||
log.info(f'Received upload URL for batch: {batch_id}')
|
||||
log.info('Received upload URL for batch: %s', batch_id)
|
||||
|
||||
return batch_id, upload_url
|
||||
|
||||
|
|
@ -334,7 +334,7 @@ class MinerULoader:
|
|||
max_iterations = 300 # 10 minutes max (2 seconds per iteration)
|
||||
poll_interval = 2 # seconds
|
||||
|
||||
log.info(f'Polling batch status: {batch_id}')
|
||||
log.info('Polling batch status: %s', batch_id)
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
try:
|
||||
|
|
@ -393,7 +393,7 @@ class MinerULoader:
|
|||
state = file_result.get('state')
|
||||
|
||||
if state == 'done':
|
||||
log.info(f'Processing complete for {filename}')
|
||||
log.info('Processing complete for %s', filename)
|
||||
return file_result
|
||||
elif state == 'failed':
|
||||
error_msg = file_result.get('err_msg', 'Unknown error')
|
||||
|
|
@ -404,7 +404,7 @@ class MinerULoader:
|
|||
elif state in ['waiting-file', 'pending', 'running', 'converting']:
|
||||
# Still processing
|
||||
if iteration % 10 == 0: # Log every 20 seconds
|
||||
log.info(f'Processing status: {state} (iteration {iteration + 1}/{max_iterations})')
|
||||
log.info('Processing status: %s (iteration %s/%s)', state, iteration + 1, max_iterations)
|
||||
time.sleep(poll_interval)
|
||||
else:
|
||||
log.warning(f'Unknown state: {state}')
|
||||
|
|
@ -421,7 +421,7 @@ class MinerULoader:
|
|||
Download ZIP file from CDN and extract markdown content.
|
||||
Returns the markdown content as a string.
|
||||
"""
|
||||
log.info(f'Downloading results from: {zip_url}')
|
||||
log.info('Downloading results from: %s', zip_url)
|
||||
|
||||
try:
|
||||
response = requests.get(zip_url, timeout=60)
|
||||
|
|
@ -452,7 +452,7 @@ class MinerULoader:
|
|||
read_errors = []
|
||||
|
||||
for member in md_members:
|
||||
log.info(f'Found markdown file in ZIP: {member.filename}')
|
||||
log.info('Found markdown file in ZIP: %s', member.filename)
|
||||
try:
|
||||
with zip_ref.open(member, 'r') as f:
|
||||
if self.max_markdown_bytes is None:
|
||||
|
|
@ -515,5 +515,5 @@ class MinerULoader:
|
|||
detail='Extracted markdown content is empty',
|
||||
)
|
||||
|
||||
log.info(f'Successfully extracted markdown content ({len(markdown_content)} characters)')
|
||||
log.info('Successfully extracted markdown content (%s characters)', len(markdown_content))
|
||||
return markdown_content
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
|
|
@ -19,15 +16,12 @@ log = logging.getLogger(__name__)
|
|||
|
||||
class MistralLoader:
|
||||
"""
|
||||
Enhanced Mistral OCR loader with both sync and async support.
|
||||
Enhanced Mistral OCR loader.
|
||||
Loads documents by processing them through the Mistral OCR API.
|
||||
|
||||
Performance Optimizations:
|
||||
- Differentiated timeouts for different operations
|
||||
- Intelligent retry logic with exponential backoff
|
||||
- Memory-efficient file streaming for large files
|
||||
- Connection pooling and keepalive optimization
|
||||
- Semaphore-based concurrency control for batch processing
|
||||
- Enhanced error handling with retryable error classification
|
||||
"""
|
||||
|
||||
|
|
@ -63,7 +57,6 @@ class MistralLoader:
|
|||
self.base_url = base_url.rstrip('/') if base_url else 'https://api.mistral.ai/v1'
|
||||
self.api_key = api_key
|
||||
self.file_path = file_path
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.debug = enable_debug_logging
|
||||
self.use_base64 = use_base64
|
||||
|
|
@ -118,32 +111,6 @@ class MistralLoader:
|
|||
log.error(f'JSON decode error: {json_err} - Response: {response.text}')
|
||||
raise # Re-raise after logging
|
||||
|
||||
async def _handle_response_async(self, response: aiohttp.ClientResponse) -> Dict[str, Any]:
|
||||
"""Async version of response handling with better error info."""
|
||||
try:
|
||||
response.raise_for_status()
|
||||
|
||||
# Check content type
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if 'application/json' not in content_type:
|
||||
if response.status == 204:
|
||||
return {}
|
||||
text = await response.text()
|
||||
raise ValueError(f'Unexpected content type: {content_type}, body: {text[:200]}...')
|
||||
|
||||
return await response.json()
|
||||
|
||||
except aiohttp.ClientResponseError as e:
|
||||
error_text = await response.text() if response else 'No response'
|
||||
log.error(f'HTTP {e.status}: {e.message} - Response: {error_text[:500]}')
|
||||
raise
|
||||
except aiohttp.ClientError as e:
|
||||
log.error(f'Client error: {e}')
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f'Unexpected error processing response: {e}')
|
||||
raise
|
||||
|
||||
def _is_retryable_error(self, error: Exception) -> bool:
|
||||
"""
|
||||
ENHANCEMENT: Intelligent error classification for retry logic.
|
||||
|
|
@ -173,10 +140,6 @@ class MistralLoader:
|
|||
status_code = error.response.status_code
|
||||
return status_code >= 500 or status_code == 429
|
||||
return False
|
||||
if isinstance(error, (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError)):
|
||||
return True # Async network/timeout errors are retryable
|
||||
if isinstance(error, aiohttp.ClientResponseError):
|
||||
return error.status >= 500 or error.status == 429
|
||||
return False # All other errors are non-retryable
|
||||
|
||||
def _retry_request_sync(self, request_func, *args, **kwargs):
|
||||
|
|
@ -203,32 +166,11 @@ class MistralLoader:
|
|||
)
|
||||
time.sleep(wait_time)
|
||||
|
||||
async def _retry_request_async(self, request_func, *args, **kwargs):
|
||||
"""
|
||||
ENHANCEMENT: Async retry logic with intelligent error classification.
|
||||
|
||||
Async version of retry logic that doesn't block the event loop during
|
||||
wait periods. Uses the same exponential backoff strategy as sync version.
|
||||
"""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
return await request_func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if attempt == self.max_retries - 1 or not self._is_retryable_error(e):
|
||||
raise
|
||||
|
||||
# PERFORMANCE OPTIMIZATION: Non-blocking exponential backoff
|
||||
wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds
|
||||
log.warning(
|
||||
f'Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s...'
|
||||
)
|
||||
await asyncio.sleep(wait_time) # Non-blocking wait
|
||||
|
||||
def _upload_file(self) -> str:
|
||||
"""
|
||||
PERFORMANCE OPTIMIZATION: Enhanced file upload with streaming consideration.
|
||||
|
||||
Uploads the file to Mistral for OCR processing (sync version).
|
||||
Uploads the file to Mistral for OCR processing.
|
||||
Uses context manager for file handling to ensure proper resource cleanup.
|
||||
Although streaming is not enabled for this endpoint, the file is opened
|
||||
in a context manager to minimize memory usage duration.
|
||||
|
|
@ -261,56 +203,15 @@ class MistralLoader:
|
|||
file_id = response_data.get('id')
|
||||
if not file_id:
|
||||
raise ValueError('File ID not found in upload response.')
|
||||
log.info(f'File uploaded successfully. File ID: {file_id}')
|
||||
log.info('File uploaded successfully. File ID: %s', file_id)
|
||||
return file_id
|
||||
except Exception as e:
|
||||
log.error(f'Failed to upload file: {e}')
|
||||
raise
|
||||
|
||||
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
|
||||
"""Async file upload with streaming for better memory efficiency."""
|
||||
url = f'{self.base_url}/files'
|
||||
|
||||
async def upload_request():
|
||||
# Open inside the request so the handle stays valid for the whole
|
||||
# streamed POST and is closed right after.
|
||||
with open(self.file_path, 'rb') as f:
|
||||
writer = aiohttp.MultipartWriter('form-data')
|
||||
|
||||
# Add purpose field
|
||||
purpose_part = writer.append('ocr')
|
||||
purpose_part.set_content_disposition('form-data', name='purpose')
|
||||
|
||||
# Stream the file. aiohttp builds a payload from the file object;
|
||||
# the previous aiohttp.streams.FilePayload was removed upstream
|
||||
# (payloads live in aiohttp.payload and there is no FilePayload),
|
||||
# so this path raised AttributeError on every async OCR upload.
|
||||
file_part = writer.append(f, {'Content-Type': 'application/pdf'})
|
||||
file_part.set_content_disposition('form-data', name='file', filename=self.file_name)
|
||||
|
||||
self._debug_log(f'Uploading file: {self.file_name} ({self.file_size:,} bytes)')
|
||||
|
||||
async with session.post(
|
||||
url,
|
||||
data=writer,
|
||||
headers=self.headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.upload_timeout),
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
return await self._handle_response_async(response)
|
||||
|
||||
response_data = await self._retry_request_async(upload_request)
|
||||
|
||||
file_id = response_data.get('id')
|
||||
if not file_id:
|
||||
raise ValueError('File ID not found in upload response.')
|
||||
|
||||
log.info(f'File uploaded successfully. File ID: {file_id}')
|
||||
return file_id
|
||||
|
||||
def _get_signed_url(self, file_id: str) -> str:
|
||||
"""Retrieves a temporary signed URL for the uploaded file (sync version)."""
|
||||
log.info(f'Getting signed URL for file ID: {file_id}')
|
||||
"""Retrieves a temporary signed URL for the uploaded file."""
|
||||
log.info('Getting signed URL for file ID: %s', file_id)
|
||||
url = f'{self.base_url}/files/{file_id}/url'
|
||||
params = {'expiry': 1}
|
||||
signed_url_headers = {**self.headers, 'Accept': 'application/json'}
|
||||
|
|
@ -330,35 +231,8 @@ class MistralLoader:
|
|||
log.error(f'Failed to get signed URL: {e}')
|
||||
raise
|
||||
|
||||
async def _get_signed_url_async(self, session: aiohttp.ClientSession, file_id: str) -> str:
|
||||
"""Async signed URL retrieval."""
|
||||
url = f'{self.base_url}/files/{file_id}/url'
|
||||
params = {'expiry': 1}
|
||||
|
||||
headers = {**self.headers, 'Accept': 'application/json'}
|
||||
|
||||
async def url_request():
|
||||
self._debug_log(f'Getting signed URL for file ID: {file_id}')
|
||||
async with session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=aiohttp.ClientTimeout(total=self.url_timeout),
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
return await self._handle_response_async(response)
|
||||
|
||||
response_data = await self._retry_request_async(url_request)
|
||||
|
||||
signed_url = response_data.get('url')
|
||||
if not signed_url:
|
||||
raise ValueError('Signed URL not found in response.')
|
||||
|
||||
self._debug_log('Signed URL received successfully')
|
||||
return signed_url
|
||||
|
||||
def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
|
||||
"""Sends the signed URL to the OCR endpoint for processing (sync version)."""
|
||||
"""Sends the signed URL to the OCR endpoint for processing."""
|
||||
log.info('Processing OCR via Mistral API')
|
||||
url = f'{self.base_url}/ocr'
|
||||
ocr_headers = {
|
||||
|
|
@ -388,113 +262,24 @@ class MistralLoader:
|
|||
log.error(f'Failed during OCR processing: {e}')
|
||||
raise
|
||||
|
||||
async def _process_ocr_async(self, session: aiohttp.ClientSession, signed_url: str) -> Dict[str, Any]:
|
||||
"""Async OCR processing with timing metrics."""
|
||||
url = f'{self.base_url}/ocr'
|
||||
|
||||
headers = {
|
||||
**self.headers,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
payload = {
|
||||
'model': 'mistral-ocr-latest',
|
||||
'document': {
|
||||
'type': 'document_url',
|
||||
'document_url': signed_url,
|
||||
},
|
||||
'include_image_base64': False,
|
||||
}
|
||||
|
||||
async def ocr_request():
|
||||
log.info('Starting OCR processing via Mistral API')
|
||||
start_time = time.time()
|
||||
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.ocr_timeout),
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
ocr_response = await self._handle_response_async(response)
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
log.info(f'OCR processing completed in {processing_time:.2f}s')
|
||||
|
||||
return ocr_response
|
||||
|
||||
return await self._retry_request_async(ocr_request)
|
||||
|
||||
def _get_file_data_url(self) -> str:
|
||||
with open(self.file_path, 'rb') as f:
|
||||
encoded_file = base64.b64encode(f.read()).decode('utf-8')
|
||||
return f'data:application/pdf;base64,{encoded_file}'
|
||||
|
||||
def _delete_file(self, file_id: str) -> None:
|
||||
"""Deletes the file from Mistral storage (sync version)."""
|
||||
log.info(f'Deleting uploaded file ID: {file_id}')
|
||||
"""Deletes the file from Mistral storage."""
|
||||
log.info('Deleting uploaded file ID: %s', file_id)
|
||||
url = f'{self.base_url}/files/{file_id}'
|
||||
|
||||
try:
|
||||
response = requests.delete(url, headers=self.headers, timeout=self.cleanup_timeout)
|
||||
delete_response = self._handle_response(response)
|
||||
log.info(f'File deleted successfully: {delete_response}')
|
||||
log.info('File deleted successfully: %s', delete_response)
|
||||
except Exception as e:
|
||||
# Log error but don't necessarily halt execution if deletion fails
|
||||
log.error(f'Failed to delete file ID {file_id}: {e}')
|
||||
|
||||
async def _delete_file_async(self, session: aiohttp.ClientSession, file_id: str) -> None:
|
||||
"""Async file deletion with error tolerance."""
|
||||
try:
|
||||
|
||||
async def delete_request():
|
||||
self._debug_log(f'Deleting file ID: {file_id}')
|
||||
async with session.delete(
|
||||
url=f'{self.base_url}/files/{file_id}',
|
||||
headers=self.headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.cleanup_timeout),
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
return await self._handle_response_async(response)
|
||||
|
||||
await self._retry_request_async(delete_request)
|
||||
self._debug_log(f'File {file_id} deleted successfully')
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail the entire process if cleanup fails
|
||||
log.warning(f'Failed to delete file ID {file_id}: {e}')
|
||||
|
||||
@asynccontextmanager
|
||||
async def _get_session(self):
|
||||
"""Context manager for HTTP session with optimized settings."""
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=20, # Increased total connection limit for better throughput
|
||||
limit_per_host=10, # Increased per-host limit for API endpoints
|
||||
ttl_dns_cache=600, # Longer DNS cache TTL (10 minutes)
|
||||
use_dns_cache=True,
|
||||
keepalive_timeout=60, # Increased keepalive for connection reuse
|
||||
enable_cleanup_closed=True,
|
||||
force_close=False, # Allow connection reuse
|
||||
resolver=aiohttp.AsyncResolver(), # Use async DNS resolver
|
||||
)
|
||||
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=self.timeout,
|
||||
connect=30, # Connection timeout
|
||||
sock_read=60, # Socket read timeout
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
timeout=timeout,
|
||||
headers={'User-Agent': 'OpenWebUI-MistralLoader/2.0'},
|
||||
raise_for_status=False, # We handle status codes manually
|
||||
trust_env=True,
|
||||
) as session:
|
||||
yield session
|
||||
|
||||
def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]:
|
||||
"""Process OCR results into Document objects with enhanced metadata and memory efficiency."""
|
||||
pages_data = ocr_response.get('pages')
|
||||
|
|
@ -519,7 +304,7 @@ class MistralLoader:
|
|||
if page_content is None or page_index is None:
|
||||
skipped_pages += 1
|
||||
self._debug_log(
|
||||
f"Skipping page due to missing 'markdown' or 'index'. Data keys: {list(page_data.keys())}"
|
||||
"Skipping page due to missing 'markdown' or 'index'. Data keys: %s", list(page_data.keys())
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
@ -531,7 +316,7 @@ class MistralLoader:
|
|||
|
||||
if not cleaned_content:
|
||||
skipped_pages += 1
|
||||
self._debug_log(f'Skipping empty page {page_index}')
|
||||
self._debug_log('Skipping empty page %s', page_index)
|
||||
continue
|
||||
|
||||
# Create document with optimized metadata
|
||||
|
|
@ -551,7 +336,7 @@ class MistralLoader:
|
|||
)
|
||||
|
||||
if skipped_pages > 0:
|
||||
log.info(f'Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages')
|
||||
log.info('Processed %s pages, skipped %s empty/invalid pages', len(documents), skipped_pages)
|
||||
|
||||
if not documents:
|
||||
# Case where pages existed but none had valid markdown/index
|
||||
|
|
@ -572,7 +357,6 @@ class MistralLoader:
|
|||
def load(self) -> List[Document]:
|
||||
"""
|
||||
Executes the full OCR workflow: upload, get URL, process OCR, delete file.
|
||||
Synchronous version for backward compatibility.
|
||||
|
||||
Returns:
|
||||
A list of Document objects, one for each page processed.
|
||||
|
|
@ -584,7 +368,7 @@ class MistralLoader:
|
|||
if self.use_base64:
|
||||
documents = self._process_results(self._process_ocr(self._get_file_data_url()))
|
||||
total_time = time.time() - start_time
|
||||
log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
|
||||
log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents))
|
||||
return documents
|
||||
|
||||
# 1. Upload file
|
||||
|
|
@ -600,7 +384,7 @@ class MistralLoader:
|
|||
documents = self._process_results(ocr_response)
|
||||
|
||||
total_time = time.time() - start_time
|
||||
log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
|
||||
log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents))
|
||||
|
||||
return documents
|
||||
|
||||
|
|
@ -625,125 +409,3 @@ class MistralLoader:
|
|||
except Exception as del_e:
|
||||
# Log deletion error, but don't overwrite original error if one occurred
|
||||
log.error(f'Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}')
|
||||
|
||||
async def load_async(self) -> List[Document]:
|
||||
"""
|
||||
Asynchronous OCR workflow execution with optimized performance.
|
||||
|
||||
Returns:
|
||||
A list of Document objects, one for each page processed.
|
||||
"""
|
||||
file_id = None
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
async with self._get_session() as session:
|
||||
if self.use_base64:
|
||||
ocr_response = await self._process_ocr_async(session, self._get_file_data_url())
|
||||
documents = self._process_results(ocr_response)
|
||||
total_time = time.time() - start_time
|
||||
log.info(f'Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
|
||||
return documents
|
||||
|
||||
# 1. Upload file with streaming
|
||||
file_id = await self._upload_file_async(session)
|
||||
|
||||
# 2. Get signed URL
|
||||
signed_url = await self._get_signed_url_async(session, file_id)
|
||||
|
||||
# 3. Process OCR
|
||||
ocr_response = await self._process_ocr_async(session, signed_url)
|
||||
|
||||
# 4. Process results
|
||||
documents = self._process_results(ocr_response)
|
||||
|
||||
total_time = time.time() - start_time
|
||||
log.info(f'Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
|
||||
|
||||
return documents
|
||||
|
||||
except Exception as e:
|
||||
total_time = time.time() - start_time
|
||||
log.error(f'Async OCR workflow failed after {total_time:.2f}s: {e}')
|
||||
return [
|
||||
Document(
|
||||
page_content=f'Error during OCR processing: {e}',
|
||||
metadata={
|
||||
'error': 'processing_failed',
|
||||
'file_name': self.file_name,
|
||||
},
|
||||
)
|
||||
]
|
||||
finally:
|
||||
# 5. Cleanup - always attempt file deletion
|
||||
if file_id:
|
||||
try:
|
||||
async with self._get_session() as session:
|
||||
await self._delete_file_async(session, file_id)
|
||||
except Exception as cleanup_error:
|
||||
log.error(f'Cleanup failed for file ID {file_id}: {cleanup_error}')
|
||||
|
||||
@staticmethod
|
||||
async def load_multiple_async(
|
||||
loaders: List['MistralLoader'],
|
||||
max_concurrent: int = 5, # Limit concurrent requests
|
||||
) -> List[List[Document]]:
|
||||
"""
|
||||
Process multiple files concurrently with controlled concurrency.
|
||||
|
||||
Args:
|
||||
loaders: List of MistralLoader instances
|
||||
max_concurrent: Maximum number of concurrent requests
|
||||
|
||||
Returns:
|
||||
List of document lists, one for each loader
|
||||
"""
|
||||
if not loaders:
|
||||
return []
|
||||
|
||||
log.info(f'Starting concurrent processing of {len(loaders)} files with max {max_concurrent} concurrent')
|
||||
start_time = time.time()
|
||||
|
||||
# Use semaphore to control concurrency
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def process_with_semaphore(loader: 'MistralLoader') -> List[Document]:
|
||||
async with semaphore:
|
||||
return await loader.load_async()
|
||||
|
||||
# Process all files with controlled concurrency
|
||||
tasks = [process_with_semaphore(loader) for loader in loaders]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Handle any exceptions in results
|
||||
processed_results = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
log.error(f'File {i} failed: {result}')
|
||||
processed_results.append(
|
||||
[
|
||||
Document(
|
||||
page_content=f'Error processing file: {result}',
|
||||
metadata={
|
||||
'error': 'batch_processing_failed',
|
||||
'file_index': i,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
processed_results.append(result)
|
||||
|
||||
# MONITORING: Log comprehensive batch processing statistics
|
||||
total_time = time.time() - start_time
|
||||
total_docs = sum(len(docs) for docs in processed_results)
|
||||
success_count = sum(1 for result in results if not isinstance(result, Exception))
|
||||
failure_count = len(results) - success_count
|
||||
|
||||
log.info(
|
||||
f'Batch processing completed in {total_time:.2f}s: '
|
||||
f'{success_count} files succeeded, {failure_count} files failed, '
|
||||
f'produced {total_docs} total documents'
|
||||
)
|
||||
|
||||
return processed_results
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class PaddleOCRVLLoader:
|
|||
self.file_name = os.path.basename(file_path)
|
||||
|
||||
def load(self) -> List[Document]:
|
||||
log.info(f'Processing with PaddleOCR-vl: {self.file_path}')
|
||||
log.info('Processing with PaddleOCR-vl: %s', self.file_path)
|
||||
|
||||
try:
|
||||
with open(self.file_path, 'rb') as file:
|
||||
|
|
@ -96,7 +96,7 @@ class PaddleOCRVLLoader:
|
|||
)
|
||||
|
||||
if skipped_pages > 0:
|
||||
log.info(f'PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.')
|
||||
log.info('PaddleOCR-vl: Processed %s pages, skipped %s empty pages.', len(documents), skipped_pages)
|
||||
|
||||
if not documents:
|
||||
log.warning('No valid text content found by PaddleOCR-vl.')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from typing import Iterator, List, Literal, Union
|
|||
import requests
|
||||
from langchain_core.document_loaders import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import TAVILY_API_BASE_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ class TavilyLoader(BaseLoader):
|
|||
self.urls = urls if isinstance(urls, list) else [urls]
|
||||
self.extract_depth = extract_depth
|
||||
self.continue_on_failure = continue_on_failure
|
||||
self.api_url = 'https://api.tavily.com/extract'
|
||||
self.api_url = f'{TAVILY_API_BASE_URL}/extract'
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
"""Extract and yield documents from the URLs using Tavily Extract API."""
|
||||
|
|
|
|||
|
|
@ -18,6 +18,32 @@ ALLOWED_NETLOCS = {
|
|||
}
|
||||
|
||||
|
||||
class YoutubeTranscriptError(Exception):
|
||||
"""A YouTube transcript could not be retrieved."""
|
||||
|
||||
|
||||
def _transcript_error_message(error: Exception, video_id: str) -> str:
|
||||
name = type(error).__name__
|
||||
|
||||
if name in {'RequestBlocked', 'IpBlocked'}:
|
||||
return (
|
||||
f'YouTube blocked the transcript request for {video_id} from this server. '
|
||||
'This usually means the server address is rate limited or belongs to a cloud '
|
||||
'provider. A proxy for these requests can be configured under Admin Settings, '
|
||||
'Web Search, Youtube Proxy URL.'
|
||||
)
|
||||
if name == 'TranscriptsDisabled':
|
||||
return f'Transcripts are disabled for the YouTube video {video_id}.'
|
||||
if name == 'AgeRestricted':
|
||||
return f'The YouTube video {video_id} is age restricted, so its transcript cannot be retrieved.'
|
||||
if name in {'VideoUnavailable', 'VideoUnplayable', 'InvalidVideoId'}:
|
||||
return f'The YouTube video {video_id} is unavailable.'
|
||||
if name == 'PoTokenRequired':
|
||||
return f'YouTube requires additional verification to return the transcript for {video_id}.'
|
||||
|
||||
return f'Could not retrieve a transcript for the YouTube video {video_id}.'
|
||||
|
||||
|
||||
def _parse_video_id(url: str) -> Optional[str]:
|
||||
"""Parse a YouTube URL and return the video ID if valid, otherwise None."""
|
||||
parsed_url = urlparse(url)
|
||||
|
|
@ -90,7 +116,7 @@ class YoutubeLoader:
|
|||
|
||||
if self.proxy_url:
|
||||
youtube_proxies = GenericProxyConfig(http_url=self.proxy_url, https_url=self.proxy_url)
|
||||
log.debug(f'Using proxy URL: {self.proxy_url[:14]}...')
|
||||
log.debug('Using proxy URL: %s...', self.proxy_url[:14])
|
||||
else:
|
||||
youtube_proxies = None
|
||||
|
||||
|
|
@ -98,31 +124,31 @@ class YoutubeLoader:
|
|||
try:
|
||||
transcript_list = transcript_api.list(self.video_id)
|
||||
except Exception as e:
|
||||
log.warning(f'Loading YouTube transcript failed: {e}')
|
||||
return []
|
||||
log.warning('Loading YouTube transcript failed: %s', e)
|
||||
raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e
|
||||
|
||||
# Try each language in order of priority
|
||||
for lang in self.language:
|
||||
try:
|
||||
transcript = transcript_list.find_transcript([lang])
|
||||
if transcript.is_generated:
|
||||
log.debug(f"Found generated transcript for language '{lang}'")
|
||||
log.debug("Found generated transcript for language '%s'", lang)
|
||||
try:
|
||||
transcript = transcript_list.find_manually_created_transcript([lang])
|
||||
log.debug(f"Found manual transcript for language '{lang}'")
|
||||
log.debug("Found manual transcript for language '%s'", lang)
|
||||
except NoTranscriptFound:
|
||||
log.debug(f"No manual transcript found for language '{lang}', using generated")
|
||||
log.debug("No manual transcript found for language '%s', using generated", lang)
|
||||
pass
|
||||
|
||||
log.debug(f"Found transcript for language '{lang}'")
|
||||
log.debug("Found transcript for language '%s'", lang)
|
||||
try:
|
||||
transcript_pieces: List[Dict[str, Any]] = transcript.fetch()
|
||||
except ParseError:
|
||||
log.debug(f"Empty or invalid transcript for language '{lang}'")
|
||||
log.debug("Empty or invalid transcript for language '%s'", lang)
|
||||
continue
|
||||
|
||||
if not transcript_pieces:
|
||||
log.debug(f"Empty transcript for language '{lang}'")
|
||||
log.debug("Empty transcript for language '%s'", lang)
|
||||
continue
|
||||
|
||||
transcript_text = ' '.join(
|
||||
|
|
@ -135,18 +161,20 @@ class YoutubeLoader:
|
|||
)
|
||||
return [Document(page_content=transcript_text, metadata=self._metadata)]
|
||||
except NoTranscriptFound:
|
||||
log.debug(f"No transcript found for language '{lang}'")
|
||||
log.debug("No transcript found for language '%s'", lang)
|
||||
continue
|
||||
except Exception as e:
|
||||
log.info(f"Error finding transcript for language '{lang}'")
|
||||
raise e
|
||||
log.info("Error finding transcript for language '%s'", lang)
|
||||
raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e
|
||||
|
||||
# If we get here, all languages failed
|
||||
languages_tried = ', '.join(self.language)
|
||||
log.warning(
|
||||
f'No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed.'
|
||||
)
|
||||
raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
|
||||
raise YoutubeTranscriptError(
|
||||
f'No transcript found for the YouTube video {self.video_id} in these languages: {languages_tried}.'
|
||||
)
|
||||
|
||||
async def aload(self) -> Generator[Document, None, None]:
|
||||
"""Asynchronously load YouTube transcripts into `Document` objects."""
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
|
|||
|
||||
class ColBERT(BaseReranker):
|
||||
def __init__(self, name, **kwargs) -> None:
|
||||
log.info('ColBERT: Loading model', name)
|
||||
log.info('ColBERT: Loading model %s', name)
|
||||
self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||||
|
||||
DOCKER = kwargs.get('env') == 'docker'
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ class ExternalReranker(BaseReranker):
|
|||
}
|
||||
|
||||
try:
|
||||
log.info(f'ExternalReranker:predict:model {self.model}')
|
||||
log.info(f'ExternalReranker:predict:query {query}')
|
||||
log.info('ExternalReranker:predict:model %s', self.model)
|
||||
log.info('ExternalReranker:predict:query %s', query)
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Awaitable, Optional, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
|
|
@ -25,6 +24,7 @@ from open_webui.config import (
|
|||
RAG_EMBEDDING_QUERY_PREFIX,
|
||||
VECTOR_DB,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
|
|
@ -50,7 +50,7 @@ from open_webui.retrieval.vector.main import GetResult, SearchResult
|
|||
from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.utils.access_control.files import get_owner_accessible_folder_files, has_access_to_file
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.headers import get_json_bearer_headers, include_user_info_headers
|
||||
from open_webui.utils.misc import get_content_from_message, get_message_list
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -68,6 +68,7 @@ def is_youtube_url(url: str) -> bool:
|
|||
|
||||
|
||||
LOADER_CONFIG_KEYS = {
|
||||
'file_max_size': 'rag.file.max_size',
|
||||
'youtube_language': 'rag.youtube_loader_language',
|
||||
'youtube_proxy_url': 'rag.youtube_loader_proxy_url',
|
||||
'web_loader_ssl_verification': 'web.loader.ssl_verification',
|
||||
|
|
@ -103,6 +104,7 @@ LOADER_CONFIG_KEYS = {
|
|||
'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key',
|
||||
'EXTERNAL_DOCUMENT_LOADER_HEADERS': 'rag.external_document_loader_headers',
|
||||
'TIKA_SERVER_URL': 'rag.tika_server_url',
|
||||
'TIKA_SERVER_VERSION': 'rag.tika_server_version',
|
||||
'DOCLING_SERVER_URL': 'rag.docling_server_url',
|
||||
'DOCLING_API_KEY': 'rag.docling_api_key',
|
||||
'DOCLING_PARAMS': 'rag.docling_params',
|
||||
|
|
@ -151,6 +153,7 @@ def build_loader_from_config(request, config: dict):
|
|||
from open_webui.retrieval.loaders.main import Loader
|
||||
|
||||
loader_config = {key: config.get(key) for key in LOADER_CONFIG_KEYS if key.isupper()}
|
||||
loader_config['FILE_MAX_SIZE'] = config.get('file_max_size')
|
||||
return Loader(
|
||||
engine=loader_config['CONTENT_EXTRACTION_ENGINE'],
|
||||
**{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'},
|
||||
|
|
@ -183,11 +186,20 @@ def _extract_text_from_binary_response(
|
|||
|
||||
suffix = '.' + filename.split('.')[-1].lower() if '.' in filename else ''
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(response.content)
|
||||
tmp_path = tmp.name
|
||||
max_size = loader_config.get('file_max_size')
|
||||
max_bytes = int(max_size) * 1024 * 1024 if max_size else 0
|
||||
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix)
|
||||
try:
|
||||
downloaded = 0
|
||||
# Stream to disk; response.content buffers the whole body in memory first.
|
||||
with os.fdopen(tmp_fd, 'wb') as tmp:
|
||||
for chunk in response.iter_content(64 * 1024):
|
||||
downloaded += len(chunk)
|
||||
if max_bytes and downloaded > max_bytes:
|
||||
raise ValueError(ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size} MB'))
|
||||
tmp.write(chunk)
|
||||
|
||||
loader = build_loader_from_config(request, loader_config)
|
||||
docs = loader.load(filename, content_type, tmp_path)
|
||||
for doc in docs:
|
||||
|
|
@ -198,14 +210,24 @@ def _extract_text_from_binary_response(
|
|||
os.remove(tmp_path)
|
||||
|
||||
|
||||
TEXT_APPLICATION_CONTENT_TYPES = {
|
||||
'application/javascript',
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/x-javascript',
|
||||
}
|
||||
|
||||
|
||||
def _is_text_content_type(content_type: str) -> bool:
|
||||
"""Return True if the content type should be handled by the web loader."""
|
||||
ct = content_type.split(';')[0].strip().lower()
|
||||
if not ct:
|
||||
return True
|
||||
if ct.startswith('text/'):
|
||||
return True
|
||||
if any(t in ct for t in ['xml', 'json', 'javascript']):
|
||||
if ct in TEXT_APPLICATION_CONTENT_TYPES:
|
||||
return True
|
||||
return not ct # empty / missing → assume HTML
|
||||
return ct.endswith(('+xml', '+json'))
|
||||
|
||||
|
||||
async def get_content_from_url(request, url: str) -> str:
|
||||
|
|
@ -218,7 +240,7 @@ async def get_content_from_url(request, url: str) -> str:
|
|||
|
||||
|
||||
def _get_content_from_url_sync(request, url: str, loader_config):
|
||||
from open_webui.retrieval.web.utils import validate_url, _SSRFSafeAdapter
|
||||
from open_webui.retrieval.web.utils import validate_url, get_ssrf_safe_requests_session
|
||||
|
||||
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
|
||||
validate_url(url)
|
||||
|
|
@ -242,9 +264,7 @@ def _get_content_from_url_sync(request, url: str, loader_config):
|
|||
# cloud-metadata 169.254.169.254) via a public host that redirects internally.
|
||||
try:
|
||||
# Probe through the connect-time SSRF guard; bare requests.get re-resolves (DNS-rebinding gap).
|
||||
session = requests.Session()
|
||||
session.mount('http://', _SSRFSafeAdapter())
|
||||
session.mount('https://', _SSRFSafeAdapter())
|
||||
session = get_ssrf_safe_requests_session()
|
||||
response = session.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
|
|
@ -311,7 +331,7 @@ class VectorSearchRetriever(BaseRetriever):
|
|||
|
||||
def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None):
|
||||
try:
|
||||
log.debug(f'query_doc:doc {collection_name}')
|
||||
log.debug('query_doc:doc %s', collection_name)
|
||||
result = VECTOR_DB_CLIENT.search(
|
||||
collection_name=collection_name,
|
||||
vectors=[query_embedding],
|
||||
|
|
@ -319,7 +339,7 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user:
|
|||
)
|
||||
|
||||
if result:
|
||||
log.info(f'query_doc:result {result.ids} {result.metadatas}')
|
||||
log.info('query_doc:result %s %s', result.ids, result.metadatas)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
|
|
@ -329,11 +349,11 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user:
|
|||
|
||||
def get_doc(collection_name: str, user: UserModel = None):
|
||||
try:
|
||||
log.debug(f'get_doc:doc {collection_name}')
|
||||
log.debug('get_doc:doc %s', collection_name)
|
||||
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
|
||||
|
||||
if result:
|
||||
log.info(f'query_doc:result {result.ids} {result.metadatas}')
|
||||
log.info('query_doc:result %s %s', result.ids, result.metadatas)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
|
|
@ -458,7 +478,7 @@ async def query_doc_with_native_hybrid_search(
|
|||
'metadatas': [metadatas],
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f'Native hybrid search failed for {collection_name}, falling back to legacy hybrid search: {e}')
|
||||
log.debug('Native hybrid search failed for %s, falling back to legacy hybrid search: %s', collection_name, e)
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -511,7 +531,7 @@ async def query_doc_with_hybrid_search(
|
|||
log.warning(f'query_doc_with_hybrid_search:no_docs {collection_name}')
|
||||
return {'documents': [], 'metadatas': [], 'distances': []}
|
||||
|
||||
log.debug(f'query_doc_with_hybrid_search:doc {collection_name}')
|
||||
log.debug('query_doc_with_hybrid_search:doc %s', collection_name)
|
||||
|
||||
original_texts = collection_result.documents[0]
|
||||
bm25_metadatas = [
|
||||
|
|
@ -586,7 +606,7 @@ async def query_doc_with_hybrid_search(
|
|||
'metadatas': [metadatas],
|
||||
}
|
||||
|
||||
log.info('query_doc_with_hybrid_search:result ' + f'{result["metadatas"]} {result["distances"]}')
|
||||
log.info('query_doc_with_hybrid_search:result %s %s', result['metadatas'], result['distances'])
|
||||
return result
|
||||
except Exception as e:
|
||||
log.exception(f'Error querying doc {collection_name} with hybrid search: {e}')
|
||||
|
|
@ -634,7 +654,7 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
|
|||
if isinstance(document, str):
|
||||
doc_hash = (metadata or {}).get(CHUNK_HASH_KEY) or _content_hash(document)
|
||||
|
||||
if doc_hash not in combined.keys():
|
||||
if doc_hash not in combined:
|
||||
combined[doc_hash] = (distance, document, metadata)
|
||||
continue # if doc is new, no further comparison is needed
|
||||
|
||||
|
|
@ -708,7 +728,7 @@ async def query_collection(
|
|||
enable_enriched_texts=config.get('rag.enable_hybrid_search_enriched_texts'),
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Hybrid search failed, falling back to vector search: {e}')
|
||||
log.debug('Hybrid search failed, falling back to vector search: %s', e)
|
||||
|
||||
results = []
|
||||
error = False
|
||||
|
|
@ -737,15 +757,15 @@ async def query_collection(
|
|||
|
||||
# Generate all query embeddings (in one call)
|
||||
query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
|
||||
log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections')
|
||||
log.debug('query_collection: processing %s queries across %s collections', len(queries), len(collection_names))
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
future_results = []
|
||||
for query_embedding in query_embeddings:
|
||||
for collection_name in collection_names:
|
||||
result = executor.submit(process_query_collection, collection_name, query_embedding)
|
||||
future_results.append(result)
|
||||
task_results = [future.result() for future in future_results]
|
||||
task_results = await asyncio.gather(
|
||||
*[
|
||||
asyncio.to_thread(process_query_collection, collection_name, query_embedding)
|
||||
for query_embedding in query_embeddings
|
||||
for collection_name in collection_names
|
||||
]
|
||||
)
|
||||
|
||||
for result, err in task_results:
|
||||
if err is not None:
|
||||
|
|
@ -813,7 +833,7 @@ async def query_collection_with_hybrid_search(
|
|||
|
||||
collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names)))
|
||||
|
||||
log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...')
|
||||
log.info('Starting hybrid search for %s queries in %s collections...', len(queries), len(collection_names))
|
||||
|
||||
async def process_query(collection_name, query):
|
||||
try:
|
||||
|
|
@ -867,15 +887,12 @@ def generate_openai_batch_embeddings(
|
|||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
log.debug('generate_openai_batch_embeddings:model %s batch size: %s', model, len(texts))
|
||||
json_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
headers = get_json_bearer_headers(key)
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
|
|
@ -900,15 +917,12 @@ async def agenerate_openai_batch_embeddings(
|
|||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
log.debug('agenerate_openai_batch_embeddings:model %s batch size: %s', model, len(texts))
|
||||
form_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
headers = get_json_bearer_headers(key)
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
|
|
@ -938,7 +952,7 @@ def generate_azure_openai_batch_embeddings(
|
|||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
|
||||
log.debug('generate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts))
|
||||
json_data = {'input': texts}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
|
@ -980,7 +994,7 @@ async def agenerate_azure_openai_batch_embeddings(
|
|||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
|
||||
log.debug('agenerate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts))
|
||||
form_data = {'input': texts}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
|
@ -1019,15 +1033,12 @@ def generate_ollama_batch_embeddings(
|
|||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
log.debug('generate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts))
|
||||
json_data = {'input': texts, 'model': model, 'truncate': True}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
headers = get_json_bearer_headers(key)
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
|
|
@ -1055,15 +1066,12 @@ async def agenerate_ollama_batch_embeddings(
|
|||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
log.debug('agenerate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts))
|
||||
form_data = {'input': texts, 'model': model, 'truncate': True}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
headers = get_json_bearer_headers(key)
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
|
|
@ -1139,7 +1147,7 @@ def get_embedding_function(
|
|||
batches = [query[i : i + embedding_batch_size] for i in range(0, len(query), embedding_batch_size)]
|
||||
|
||||
if enable_async:
|
||||
log.debug(f'generate_multiple_async: Processing {len(batches)} batches in parallel')
|
||||
log.debug('generate_multiple_async: Processing %s batches in parallel', len(batches))
|
||||
# Use semaphore to limit concurrent embedding API requests
|
||||
# 0 = unlimited (no semaphore)
|
||||
if concurrent_requests:
|
||||
|
|
@ -1154,7 +1162,7 @@ def get_embedding_function(
|
|||
tasks = [embedding_function(batch, prefix=prefix, user=user) for batch in batches]
|
||||
batch_results = await asyncio.gather(*tasks)
|
||||
else:
|
||||
log.debug(f'generate_multiple_async: Processing {len(batches)} batches sequentially')
|
||||
log.debug('generate_multiple_async: Processing %s batches sequentially', len(batches))
|
||||
batch_results = []
|
||||
for batch in batches:
|
||||
batch_results.append(await embedding_function(batch, prefix=prefix, user=user))
|
||||
|
|
@ -1167,7 +1175,9 @@ def get_embedding_function(
|
|||
embeddings.extend(batch_embeddings)
|
||||
|
||||
log.debug(
|
||||
f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches'
|
||||
'generate_multiple_async: Generated %s embeddings from %s parallel batches',
|
||||
len(embeddings),
|
||||
len(batches),
|
||||
)
|
||||
return embeddings
|
||||
else:
|
||||
|
|
@ -1415,8 +1425,21 @@ async def get_sources_from_items(
|
|||
elif item.get('type') == 'chat':
|
||||
# Chat Attached
|
||||
chat = await Chats.get_chat_by_id(item.get('id'))
|
||||
has_read_access = bool(chat and (user.role == 'admin' or chat.user_id == user.id))
|
||||
|
||||
if chat and (user.role == 'admin' or chat.user_id == user.id):
|
||||
if chat and not has_read_access:
|
||||
has_read_access = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=chat.id,
|
||||
permission='read',
|
||||
)
|
||||
|
||||
if chat and not has_read_access and chat.folder_id:
|
||||
folder = await Folders.get_folder_by_id(chat.folder_id)
|
||||
has_read_access = folder and await has_folder_access(user.id, folder, 'read', db=None)
|
||||
|
||||
if has_read_access:
|
||||
messages_map = chat.chat.get('history', {}).get('messages', {})
|
||||
message_id = chat.chat.get('history', {}).get('currentId')
|
||||
|
||||
|
|
@ -1607,14 +1630,14 @@ async def get_sources_from_items(
|
|||
if query_result is None and collection_names:
|
||||
collection_names = set(collection_names).difference(extracted_collections)
|
||||
if not collection_names:
|
||||
log.debug(f'skipping {item} as it has already been extracted')
|
||||
log.debug('skipping %s as it has already been extracted', item)
|
||||
continue
|
||||
|
||||
# Filter out collections the user cannot read
|
||||
if user and (item.get('type'), item.get('id')) not in folder_items:
|
||||
collection_names = await filter_accessible_collections(collection_names, user)
|
||||
if not collection_names:
|
||||
log.debug(f'access denied for all collections in item {item}')
|
||||
log.debug('access denied for all collections in item %s', item)
|
||||
continue
|
||||
|
||||
try:
|
||||
|
|
@ -1673,8 +1696,8 @@ def get_model_path(model: str, update_model: bool = False):
|
|||
'local_files_only': local_files_only,
|
||||
}
|
||||
|
||||
log.debug(f'model: {model}')
|
||||
log.debug(f'snapshot_kwargs: {snapshot_kwargs}')
|
||||
log.debug('model: %s', model)
|
||||
log.debug('snapshot_kwargs: %s', snapshot_kwargs)
|
||||
|
||||
# Inspiration from upstream sentence_transformers
|
||||
if os.path.exists(model) or ('\\' in model or model.count('/') > 1) and local_files_only:
|
||||
|
|
@ -1689,7 +1712,7 @@ def get_model_path(model: str, update_model: bool = False):
|
|||
# Attempt to query the huggingface_hub library to determine the local path and/or to update
|
||||
try:
|
||||
model_repo_path = snapshot_download(**snapshot_kwargs)
|
||||
log.debug(f'model_repo_path: {model_repo_path}')
|
||||
log.debug('model_repo_path: %s', model_repo_path)
|
||||
return model_repo_path
|
||||
except Exception as e:
|
||||
log.exception(f'Cannot determine model snapshot path: {e}')
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ class ChromaClient(VectorDBBase):
|
|||
collection.delete(where=filter)
|
||||
except Exception as e:
|
||||
# If collection doesn't exist, that's fine - nothing to delete
|
||||
log.debug(f'Attempted to delete from non-existent collection {collection_name}. Ignoring.')
|
||||
log.debug('Attempted to delete from non-existent collection %s. Ignoring.', collection_name)
|
||||
pass
|
||||
|
||||
def reset(self):
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on
|
|||
"""
|
||||
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from elasticsearch import BadRequestError, Elasticsearch
|
||||
from elasticsearch.helpers import bulk, scan
|
||||
|
|
@ -23,7 +23,13 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
|
||||
|
||||
|
||||
def _metadata_filter(key: str, op: str, value: Any) -> dict:
|
||||
if op == '$in':
|
||||
return {'terms': {f'metadata.{key}': value}}
|
||||
return {'term': {f'metadata.{key}': value}}
|
||||
|
||||
|
||||
class ElasticsearchClient(VectorDBBase):
|
||||
|
|
@ -161,12 +167,16 @@ class ElasticsearchClient(VectorDBBase):
|
|||
filter: Optional[dict] = None,
|
||||
limit: int = 10,
|
||||
) -> Optional[SearchResult]:
|
||||
filters = [{'term': {'collection': collection_name}}]
|
||||
if filter:
|
||||
filters.extend(_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter))
|
||||
|
||||
query = {
|
||||
'size': limit,
|
||||
'_source': ['text', 'metadata'],
|
||||
'query': {
|
||||
'script_score': {
|
||||
'query': {'bool': {'filter': [{'term': {'collection': collection_name}}]}},
|
||||
'query': {'bool': {'filter': filters}},
|
||||
'script': {
|
||||
'source': "cosineSimilarity(params.vector, 'vector') + 1.0",
|
||||
'params': {'vector': vectors[0]}, # Assuming single query vector
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ NOTE: This vector database integration is community-supported and maintained on
|
|||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
|
|
@ -30,6 +29,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ def _safe_json(v: Any) -> Dict[str, Any]:
|
|||
return {}
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
j = json.loads(v)
|
||||
j = JSONCodec.loads(v)
|
||||
return j if isinstance(j, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
|
@ -324,7 +324,7 @@ class MariaDBVectorClient(VectorDBBase):
|
|||
emb,
|
||||
collection_name,
|
||||
item.get('text'),
|
||||
json.dumps(meta),
|
||||
JSONCodec.dumps(meta),
|
||||
)
|
||||
)
|
||||
cur.executemany(sql, params)
|
||||
|
|
@ -367,7 +367,7 @@ class MariaDBVectorClient(VectorDBBase):
|
|||
emb,
|
||||
collection_name,
|
||||
item.get('text'),
|
||||
json.dumps(meta),
|
||||
JSONCodec.dumps(meta),
|
||||
)
|
||||
)
|
||||
cur.executemany(sql, params)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from open_webui.config import (
|
||||
MILVUS_DB,
|
||||
|
|
@ -24,7 +24,8 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from pymilvus import DataType
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
|
@ -35,6 +36,36 @@ log = logging.getLogger(__name__)
|
|||
# field). Clamp long chunks before insert so one oversized chunk can't fail the
|
||||
# whole batch and leave the file with zero embeddings.
|
||||
MILVUS_TEXT_MAX_LENGTH = 65535
|
||||
_SAFE_METADATA_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
|
||||
|
||||
|
||||
def _escape_milvus_string(value: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f'Expected str, got {type(value).__name__}')
|
||||
return value.replace('\\', '\\\\').replace("'", "\\'")
|
||||
|
||||
|
||||
def _milvus_literal(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return f"'{_escape_milvus_string(value)}'"
|
||||
if isinstance(value, bool):
|
||||
return str(value).lower()
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
raise TypeError(f'Unsupported Milvus filter value type: {type(value).__name__}')
|
||||
|
||||
|
||||
def _metadata_exprs(filter: Optional[dict]) -> list[str]:
|
||||
exprs = []
|
||||
for key, op, value in iter_filter_conditions(filter):
|
||||
if not isinstance(key, str) or not _SAFE_METADATA_KEY_RE.fullmatch(key):
|
||||
raise ValueError(f'Invalid Milvus metadata filter key: {key!r}')
|
||||
if op == '$in':
|
||||
items = [f"metadata['{key}'] == {_milvus_literal(item)}" for item in value]
|
||||
exprs.append(f'({" or ".join(items)})' if items else 'false')
|
||||
else:
|
||||
exprs.append(f"metadata['{key}'] == {_milvus_literal(value)}")
|
||||
return exprs
|
||||
|
||||
|
||||
class MilvusClient(VectorDBBase):
|
||||
|
|
@ -125,7 +156,7 @@ class MilvusClient(VectorDBBase):
|
|||
index_type = MILVUS_INDEX_TYPE.upper()
|
||||
metric_type = MILVUS_METRIC_TYPE.upper()
|
||||
|
||||
log.info(f'Using Milvus index type: {index_type}, metric type: {metric_type}')
|
||||
log.info('Using Milvus index type: %s, metric type: %s', index_type, metric_type)
|
||||
|
||||
index_creation_params = {}
|
||||
if index_type == 'HNSW':
|
||||
|
|
@ -133,18 +164,18 @@ class MilvusClient(VectorDBBase):
|
|||
'M': MILVUS_HNSW_M,
|
||||
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
|
||||
}
|
||||
log.info(f'HNSW params: {index_creation_params}')
|
||||
log.info('HNSW params: %s', index_creation_params)
|
||||
elif index_type == 'IVF_FLAT':
|
||||
index_creation_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
|
||||
log.info(f'IVF_FLAT params: {index_creation_params}')
|
||||
log.info('IVF_FLAT params: %s', index_creation_params)
|
||||
elif index_type == 'DISKANN':
|
||||
index_creation_params = {
|
||||
'max_degree': MILVUS_DISKANN_MAX_DEGREE,
|
||||
'search_list_size': MILVUS_DISKANN_SEARCH_LIST_SIZE,
|
||||
}
|
||||
log.info(f'DISKANN params: {index_creation_params}')
|
||||
log.info('DISKANN params: %s', index_creation_params)
|
||||
elif index_type in ['FLAT', 'AUTOINDEX']:
|
||||
log.info(f'Using {index_type} index with no specific build-time params.')
|
||||
log.info('Using %s index with no specific build-time params.', index_type)
|
||||
else:
|
||||
log.warning(
|
||||
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
|
||||
|
|
@ -167,7 +198,11 @@ class MilvusClient(VectorDBBase):
|
|||
index_params=index_params,
|
||||
)
|
||||
log.info(
|
||||
f"Successfully created collection '{self.collection_prefix}_{collection_name}' with index type '{index_type}' and metric '{metric_type}'."
|
||||
"Successfully created collection '%s_%s' with index type '%s' and metric '%s'.",
|
||||
self.collection_prefix,
|
||||
collection_name,
|
||||
index_type,
|
||||
metric_type,
|
||||
)
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
|
|
@ -189,6 +224,9 @@ class MilvusClient(VectorDBBase):
|
|||
) -> Optional[SearchResult]:
|
||||
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
|
||||
collection_name = collection_name.replace('-', '_')
|
||||
kwargs = {}
|
||||
if filter:
|
||||
kwargs['filter'] = ' and '.join(_metadata_exprs(filter))
|
||||
# For some index types like IVF_FLAT, search params like nprobe can be set.
|
||||
# Example: search_params = {"nprobe": 10} if using IVF_FLAT
|
||||
# For simplicity, not adding configurable search_params here, but could be extended.
|
||||
|
|
@ -197,6 +235,7 @@ class MilvusClient(VectorDBBase):
|
|||
data=vectors,
|
||||
limit=limit,
|
||||
output_fields=['data', 'metadata'],
|
||||
**kwargs,
|
||||
# search_params=search_params # Potentially add later if needed
|
||||
)
|
||||
return self._result_to_search_result(result)
|
||||
|
|
@ -220,7 +259,11 @@ class MilvusClient(VectorDBBase):
|
|||
|
||||
try:
|
||||
log.info(
|
||||
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
||||
"Querying collection %s_%s with filter: '%s', limit: %s",
|
||||
self.collection_prefix,
|
||||
collection_name,
|
||||
filter_string,
|
||||
limit,
|
||||
)
|
||||
|
||||
iterator = self.client.query_iterator(
|
||||
|
|
@ -242,7 +285,7 @@ class MilvusClient(VectorDBBase):
|
|||
break
|
||||
all_results.extend(batch)
|
||||
|
||||
log.debug(f'Total results from query: {len(all_results)}')
|
||||
log.debug('Total results from query: %s', len(all_results))
|
||||
return self._result_to_get_result([all_results] if all_results else [[]])
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -265,7 +308,7 @@ class MilvusClient(VectorDBBase):
|
|||
# Insert the items into the collection, if the collection does not exist, it will be created.
|
||||
collection_name = collection_name.replace('-', '_')
|
||||
if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'):
|
||||
log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist. Creating now.')
|
||||
log.info('Collection %s_%s does not exist. Creating now.', self.collection_prefix, collection_name)
|
||||
if not items:
|
||||
log.error(
|
||||
f'Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension.'
|
||||
|
|
@ -273,7 +316,7 @@ class MilvusClient(VectorDBBase):
|
|||
raise ValueError('Cannot create Milvus collection without items to determine vector dimension.')
|
||||
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
|
||||
|
||||
log.info(f'Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
|
||||
log.info('Inserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name)
|
||||
data = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
|
|
@ -301,7 +344,9 @@ class MilvusClient(VectorDBBase):
|
|||
# Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created.
|
||||
collection_name = collection_name.replace('-', '_')
|
||||
if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'):
|
||||
log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now.')
|
||||
log.info(
|
||||
'Collection %s_%s does not exist for upsert. Creating now.', self.collection_prefix, collection_name
|
||||
)
|
||||
if not items:
|
||||
log.error(
|
||||
f'Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension.'
|
||||
|
|
@ -311,7 +356,7 @@ class MilvusClient(VectorDBBase):
|
|||
)
|
||||
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
|
||||
|
||||
log.info(f'Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
|
||||
log.info('Upserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name)
|
||||
data = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
|
|
@ -348,15 +393,20 @@ class MilvusClient(VectorDBBase):
|
|||
return None
|
||||
|
||||
if ids:
|
||||
log.info(f'Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}')
|
||||
log.info('Deleting items by IDs from %s_%s. IDs: %s', self.collection_prefix, collection_name, ids)
|
||||
return self.client.delete(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
ids=ids,
|
||||
)
|
||||
elif filter:
|
||||
filter_string = ' && '.join([f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items()])
|
||||
filter_string = ' && '.join(
|
||||
[f'metadata["{key}"] == {JSONCodec.dumps(value)}' for key, value in filter.items()]
|
||||
)
|
||||
log.info(
|
||||
f'Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}'
|
||||
'Deleting items by filter from %s_%s. Filter: %s',
|
||||
self.collection_prefix,
|
||||
collection_name,
|
||||
filter_string,
|
||||
)
|
||||
return self.client.delete(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
|
|
@ -378,7 +428,7 @@ class MilvusClient(VectorDBBase):
|
|||
try:
|
||||
self.client.drop_collection(collection_name=collection_name_full)
|
||||
deleted_collections.append(collection_name_full)
|
||||
log.info(f'Deleted collection: {collection_name_full}')
|
||||
log.info('Deleted collection: %s', collection_name_full)
|
||||
except Exception as e:
|
||||
log.error(f'Error deleting collection {collection_name_full}: {e}')
|
||||
log.info(f'Milvus reset complete. Deleted collections: {deleted_collections}')
|
||||
log.info('Milvus reset complete. Deleted collections: %s', deleted_collections)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from open_webui.config import (
|
|||
MILVUS_TOKEN,
|
||||
MILVUS_URI,
|
||||
)
|
||||
from open_webui.retrieval.vector.dbs.milvus import _metadata_exprs
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
|
|
@ -146,7 +147,7 @@ class MilvusClient(VectorDBBase):
|
|||
# The index only accelerates resource_id filters; never fail
|
||||
# collection creation over it.
|
||||
log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}')
|
||||
log.info(f'Created shared collection: {mt_collection_name}')
|
||||
log.info('Created shared collection: %s', mt_collection_name)
|
||||
|
||||
def _ensure_collection(self, mt_collection_name: str, dimension: int):
|
||||
if not self.client.has_collection(mt_collection_name):
|
||||
|
|
@ -221,13 +222,14 @@ class MilvusClient(VectorDBBase):
|
|||
|
||||
self.client.load_collection(mt_collection)
|
||||
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'", *_metadata_exprs(filter)]
|
||||
results = self.client.search(
|
||||
collection_name=mt_collection,
|
||||
data=vectors,
|
||||
anns_field='vector',
|
||||
search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}},
|
||||
limit=limit,
|
||||
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
filter=' and '.join(expr),
|
||||
output_fields=['id', 'text', 'metadata'],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -63,20 +62,18 @@ from open_webui.config import (
|
|||
OPENGAUSS_POOL_SIZE,
|
||||
OPENGAUSS_POOL_TIMEOUT,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
|
||||
|
||||
VECTOR_LENGTH = OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH
|
||||
Base = declarative_base()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS['RAG'])
|
||||
|
||||
|
||||
class DocumentChunk(Base):
|
||||
|
|
@ -89,6 +86,12 @@ class DocumentChunk(Base):
|
|||
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
|
||||
|
||||
|
||||
def _metadata_clause(key: str, op: str, value: Any):
|
||||
if op == '$in':
|
||||
return DocumentChunk.vmetadata[key].astext.in_([str(v) for v in value])
|
||||
return DocumentChunk.vmetadata[key].astext == str(value)
|
||||
|
||||
|
||||
class OpenGaussClient(VectorDBBase):
|
||||
def __init__(self) -> None:
|
||||
if not OPENGAUSS_DB_URL:
|
||||
|
|
@ -182,7 +185,7 @@ class OpenGaussClient(VectorDBBase):
|
|||
new_items.append(new_chunk)
|
||||
self.session.bulk_save_objects(new_items)
|
||||
self.session.commit()
|
||||
log.info(f"Inserting {len(new_items)} items into collection '{collection_name}'.")
|
||||
log.info("Inserting %s items into collection '%s'.", len(new_items), collection_name)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Failed to insert data: {e}')
|
||||
|
|
@ -208,7 +211,7 @@ class OpenGaussClient(VectorDBBase):
|
|||
)
|
||||
self.session.add(new_chunk)
|
||||
self.session.commit()
|
||||
log.info(f"Inserting/updating {len(items)} items in collection '{collection_name}'.")
|
||||
log.info("Inserting/updating %s items in collection '%s'.", len(items), collection_name)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Failed to insert or update data.: {e}')
|
||||
|
|
@ -245,10 +248,15 @@ class OpenGaussClient(VectorDBBase):
|
|||
DocumentChunk.vmetadata,
|
||||
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label('distance'),
|
||||
]
|
||||
where_clauses = [DocumentChunk.collection_name == collection_name]
|
||||
if filter:
|
||||
where_clauses.extend(
|
||||
_metadata_clause(key, op, value) for key, op, value in iter_filter_conditions(filter)
|
||||
)
|
||||
|
||||
subq = (
|
||||
select(*result_fields)
|
||||
.where(DocumentChunk.collection_name == collection_name)
|
||||
.where(*where_clauses)
|
||||
.order_by(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
|
||||
)
|
||||
if limit is not None:
|
||||
|
|
@ -353,7 +361,7 @@ class OpenGaussClient(VectorDBBase):
|
|||
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
|
||||
deleted = query.delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
log.info(f"Deleted {deleted} items from collection '{collection_name}'")
|
||||
log.info("Deleted %s items from collection '%s'", deleted, collection_name)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Failed to delete data: {e}')
|
||||
|
|
@ -363,7 +371,7 @@ class OpenGaussClient(VectorDBBase):
|
|||
try:
|
||||
deleted = self.session.query(DocumentChunk).delete()
|
||||
self.session.commit()
|
||||
log.info(f'Reset completed. Deleted {deleted} items')
|
||||
log.info('Reset completed. Deleted %s items', deleted)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Reset failed: {e}')
|
||||
|
|
@ -387,4 +395,4 @@ class OpenGaussClient(VectorDBBase):
|
|||
|
||||
def delete_collection(self, collection_name: str) -> None:
|
||||
self.delete(collection_name)
|
||||
log.info(f"Collection '{collection_name}' has been deleted")
|
||||
log.info("Collection '%s' has been deleted", collection_name)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from open_webui.config import (
|
||||
OPENSEARCH_CERT_VERIFY,
|
||||
|
|
@ -17,11 +17,17 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
|
||||
from opensearchpy import OpenSearch
|
||||
from opensearchpy.helpers import bulk
|
||||
|
||||
|
||||
def _metadata_filter(key: str, op: str, value: Any) -> dict:
|
||||
if op == '$in':
|
||||
return {'terms': {f'metadata.{key}.keyword': value}}
|
||||
return {'term': {f'metadata.{key}.keyword': value}}
|
||||
|
||||
|
||||
class OpenSearchClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.index_prefix = 'open_webui'
|
||||
|
|
@ -121,6 +127,8 @@ class OpenSearchClient(VectorDBBase):
|
|||
filter: Optional[dict] = None,
|
||||
limit: int = 10,
|
||||
) -> Optional[SearchResult]:
|
||||
filter_clauses = [_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)]
|
||||
|
||||
try:
|
||||
if not self.has_collection(collection_name):
|
||||
return None
|
||||
|
|
@ -130,7 +138,7 @@ class OpenSearchClient(VectorDBBase):
|
|||
'_source': ['text', 'metadata'],
|
||||
'query': {
|
||||
'script_score': {
|
||||
'query': {'match_all': {}},
|
||||
'query': {'bool': {'filter': filter_clauses}} if filter_clauses else {'match_all': {}},
|
||||
'script': {
|
||||
'source': '(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0',
|
||||
'params': {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import array
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from decimal import Decimal
|
||||
|
|
@ -56,8 +57,29 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_SAFE_METADATA_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
|
||||
|
||||
|
||||
def _metadata_where(filter: Optional[dict]) -> tuple[str, dict[str, Any]]:
|
||||
clause = ''
|
||||
params: dict[str, Any] = {}
|
||||
for i, (key, op, value) in enumerate(iter_filter_conditions(filter)):
|
||||
if not isinstance(key, str) or not _SAFE_METADATA_KEY_RE.fullmatch(key):
|
||||
raise ValueError(f'Invalid Oracle metadata filter key: {key!r}')
|
||||
json_value = f"JSON_VALUE(dc.vmetadata, '$.{key}' RETURNING VARCHAR2(4096))"
|
||||
if op == '$in':
|
||||
names = [f'value_{i}_{j}' for j, _ in enumerate(value)]
|
||||
clause += f' AND {json_value} IN ({", ".join(f":{name}" for name in names)})' if names else ' AND 1 = 0'
|
||||
params.update({name: str(item) for name, item in zip(names, value)})
|
||||
else:
|
||||
name = f'value_{i}'
|
||||
clause += f' AND {json_value} = :{name}'
|
||||
params[name] = str(value)
|
||||
return clause, params
|
||||
|
||||
|
||||
class Oracle23aiClient(VectorDBBase):
|
||||
|
|
@ -93,10 +115,10 @@ class Oracle23aiClient(VectorDBBase):
|
|||
self._create_dbcs_pool()
|
||||
|
||||
dsn = ORACLE_DB_DSN
|
||||
log.info(f'Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]')
|
||||
log.info('Creating Connection Pool [%s:**@%s]', ORACLE_DB_USER, dsn)
|
||||
|
||||
with self.get_connection() as connection:
|
||||
log.info(f'Connection version: {connection.version}')
|
||||
log.info('Connection version: %s', connection.version)
|
||||
self._initialize_database(connection)
|
||||
|
||||
log.info('Oracle Vector Search initialization complete.')
|
||||
|
|
@ -158,7 +180,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2**attempt
|
||||
log.info(f'Retrying in {wait_time} seconds...')
|
||||
log.info('Retrying in %s seconds...', wait_time)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise
|
||||
|
|
@ -183,7 +205,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
|
||||
thread = threading.Thread(target=_monitor, daemon=True)
|
||||
thread.start()
|
||||
log.info(f'Started DB health monitor every {interval_seconds} seconds.')
|
||||
log.info('Started DB health monitor every %s seconds.', interval_seconds)
|
||||
|
||||
def _reconnect_pool(self):
|
||||
"""
|
||||
|
|
@ -390,7 +412,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
Returns:
|
||||
Dict: Metadata dictionary
|
||||
"""
|
||||
return json.loads(json_str) if json_str else {}
|
||||
return JSONCodec.loads(json_str) if json_str else {}
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""
|
||||
|
|
@ -411,7 +433,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
... ]
|
||||
>>> client.insert("my_collection", items)
|
||||
"""
|
||||
log.info(f"Inserting {len(items)} items into collection '{collection_name}'.")
|
||||
log.info("Inserting %s items into collection '%s'.", len(items), collection_name)
|
||||
|
||||
with self.get_connection() as connection:
|
||||
try:
|
||||
|
|
@ -436,7 +458,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
)
|
||||
|
||||
connection.commit()
|
||||
log.info(f"Successfully inserted {len(items)} items into collection '{collection_name}'.")
|
||||
log.info("Successfully inserted %s items into collection '%s'.", len(items), collection_name)
|
||||
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
|
|
@ -465,7 +487,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
... ]
|
||||
>>> client.upsert("my_collection", items)
|
||||
"""
|
||||
log.info(f"Upserting {len(items)} items into collection '{collection_name}'.")
|
||||
log.info("Upserting %s items into collection '%s'.", len(items), collection_name)
|
||||
|
||||
with self.get_connection() as connection:
|
||||
try:
|
||||
|
|
@ -504,7 +526,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
)
|
||||
|
||||
connection.commit()
|
||||
log.info(f"Successfully upserted {len(items)} items into collection '{collection_name}'.")
|
||||
log.info("Successfully upserted %s items into collection '%s'.", len(items), collection_name)
|
||||
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
|
|
@ -540,7 +562,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])):
|
||||
... log.info(f"Match {i+1}: id={id}, distance={dist}")
|
||||
"""
|
||||
log.info(f"Searching items from collection '{collection_name}' with limit {limit}.")
|
||||
log.info("Searching items from collection '%s' with limit %s.", collection_name, limit)
|
||||
|
||||
try:
|
||||
if not vectors:
|
||||
|
|
@ -548,6 +570,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
return None
|
||||
|
||||
num_queries = len(vectors)
|
||||
filter_clause, filter_params = _metadata_where(filter)
|
||||
|
||||
ids = [[] for _ in range(num_queries)]
|
||||
distances = [[] for _ in range(num_queries)]
|
||||
|
|
@ -560,12 +583,12 @@ class Oracle23aiClient(VectorDBBase):
|
|||
vector_blob = self._vector_to_blob(vector)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dc.id, dc.text,
|
||||
f"""
|
||||
SELECT dc.id, dc.text,
|
||||
JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata,
|
||||
VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance
|
||||
FROM document_chunk dc
|
||||
WHERE dc.collection_name = :collection_name
|
||||
WHERE dc.collection_name = :collection_name{filter_clause}
|
||||
ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE)
|
||||
FETCH APPROX FIRST :limit ROWS ONLY
|
||||
""",
|
||||
|
|
@ -573,6 +596,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
'query_vector': vector_blob,
|
||||
'collection_name': collection_name,
|
||||
'limit': limit,
|
||||
**filter_params,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -586,7 +610,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
metadatas[qid].append(self._json_to_metadata(metadata_str))
|
||||
distances[qid].append(float(row[3]))
|
||||
|
||||
log.info(f'Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results.')
|
||||
log.info('Search completed. Found %s total results.', sum(len(ids[i]) for i in range(num_queries)))
|
||||
|
||||
return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas)
|
||||
|
||||
|
|
@ -615,7 +639,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
>>> if results:
|
||||
... print(f"Found {len(results.ids[0])} matching documents")
|
||||
"""
|
||||
log.info(f"Querying items from collection '{collection_name}' with filters.")
|
||||
log.info("Querying items from collection '%s' with filters.", collection_name)
|
||||
|
||||
try:
|
||||
limit = limit or 100
|
||||
|
|
@ -655,7 +679,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
]
|
||||
]
|
||||
|
||||
log.info(f'Query completed. Found {len(results)} results.')
|
||||
log.info('Query completed. Found %s results.', len(results))
|
||||
|
||||
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
|
||||
|
||||
|
|
@ -746,7 +770,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
>>> # Or delete by metadata filter
|
||||
>>> client.delete("my_collection", filter={"source": "deprecated_source"})
|
||||
"""
|
||||
log.info(f"Deleting items from collection '{collection_name}'.")
|
||||
log.info("Deleting items from collection '%s'.", collection_name)
|
||||
|
||||
try:
|
||||
query = 'DELETE FROM document_chunk WHERE collection_name = :collection_name'
|
||||
|
|
@ -771,7 +795,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
deleted = cursor.rowcount
|
||||
connection.commit()
|
||||
|
||||
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
|
||||
log.info("Deleted %s items from collection '%s'.", deleted, collection_name)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f'Error during delete: {e}')
|
||||
|
|
@ -799,7 +823,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
deleted = cursor.rowcount
|
||||
connection.commit()
|
||||
|
||||
log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.")
|
||||
log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f'Error during reset: {e}')
|
||||
|
|
@ -874,7 +898,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
>>> client = Oracle23aiClient()
|
||||
>>> client.delete_collection("obsolete_collection")
|
||||
"""
|
||||
log.info(f"Deleting collection '{collection_name}'.")
|
||||
log.info("Deleting collection '%s'.", collection_name)
|
||||
|
||||
try:
|
||||
with self.get_connection() as connection:
|
||||
|
|
@ -890,7 +914,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
deleted = cursor.rowcount
|
||||
connection.commit()
|
||||
|
||||
log.info(f"Collection '{collection_name}' deleted. Removed {deleted} items.")
|
||||
log.info("Collection '%s' deleted. Removed %s items.", collection_name, deleted)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error deleting collection '{collection_name}': {e}")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
|
@ -18,6 +17,7 @@ from open_webui.config import (
|
|||
PGVECTOR_POOL_TIMEOUT,
|
||||
PGVECTOR_USE_HALFVEC,
|
||||
)
|
||||
from open_webui.internal.db import ScopedSession, enable_iam_token_auth
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
|
|
@ -25,6 +25,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import merge_hybrid_search_results, process_metadata
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.misc import sanitize_text_for_db
|
||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
||||
from sqlalchemy import (
|
||||
|
|
@ -87,8 +88,6 @@ class PgvectorClient(VectorDBBase):
|
|||
def __init__(self) -> None:
|
||||
# if no pgvector uri, use the existing database connection
|
||||
if not PGVECTOR_DB_URL:
|
||||
from open_webui.internal.db import ScopedSession
|
||||
|
||||
self.session = ScopedSession
|
||||
else:
|
||||
if isinstance(PGVECTOR_POOL_SIZE, int):
|
||||
|
|
@ -107,6 +106,7 @@ class PgvectorClient(VectorDBBase):
|
|||
else:
|
||||
engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True)
|
||||
|
||||
enable_iam_token_auth(engine)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
||||
self.session = scoped_session(SessionLocal)
|
||||
|
||||
|
|
@ -303,7 +303,7 @@ class PgvectorClient(VectorDBBase):
|
|||
# Use raw SQL for BYTEA/pgcrypto
|
||||
# Ensure metadata is converted to its JSON text representation
|
||||
# Sanitize to strip null bytes / surrogates that PostgreSQL cannot store
|
||||
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
|
||||
json_metadata = sanitize_text_for_db(JSONCodec.dumps(item['metadata']))
|
||||
item_text = sanitize_text_for_db(item['text'])
|
||||
self.session.execute(
|
||||
text("""
|
||||
|
|
@ -326,7 +326,7 @@ class PgvectorClient(VectorDBBase):
|
|||
},
|
||||
)
|
||||
self.session.commit()
|
||||
log.info(f"Encrypted & inserted {len(items)} into '{collection_name}'")
|
||||
log.info("Encrypted & inserted %s into '%s'", len(items), collection_name)
|
||||
|
||||
else:
|
||||
new_items = []
|
||||
|
|
@ -342,7 +342,7 @@ class PgvectorClient(VectorDBBase):
|
|||
new_items.append(new_chunk)
|
||||
self.session.bulk_save_objects(new_items)
|
||||
self.session.commit()
|
||||
log.info(f"Inserted {len(new_items)} items into collection '{collection_name}'.")
|
||||
log.info("Inserted %s items into collection '%s'.", len(new_items), collection_name)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Error during insert: {e}')
|
||||
|
|
@ -354,7 +354,7 @@ class PgvectorClient(VectorDBBase):
|
|||
for item in items:
|
||||
vector = self.adjust_vector_length(item['vector'])
|
||||
# Sanitize to strip null bytes / surrogates that PostgreSQL cannot store
|
||||
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
|
||||
json_metadata = sanitize_text_for_db(JSONCodec.dumps(item['metadata']))
|
||||
item_text = sanitize_text_for_db(item['text'])
|
||||
self.session.execute(
|
||||
text("""
|
||||
|
|
@ -381,7 +381,7 @@ class PgvectorClient(VectorDBBase):
|
|||
},
|
||||
)
|
||||
self.session.commit()
|
||||
log.info(f"Encrypted & upserted {len(items)} into '{collection_name}'")
|
||||
log.info("Encrypted & upserted %s into '%s'", len(items), collection_name)
|
||||
else:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item['vector'])
|
||||
|
|
@ -401,7 +401,7 @@ class PgvectorClient(VectorDBBase):
|
|||
)
|
||||
self.session.add(new_chunk)
|
||||
self.session.commit()
|
||||
log.info(f"Upserted {len(items)} items into collection '{collection_name}'.")
|
||||
log.info("Upserted %s items into collection '%s'.", len(items), collection_name)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Error during upsert: {e}')
|
||||
|
|
@ -712,7 +712,7 @@ class PgvectorClient(VectorDBBase):
|
|||
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
|
||||
deleted = query.delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
|
||||
log.info("Deleted %s items from collection '%s'.", deleted, collection_name)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Error during delete: {e}')
|
||||
|
|
@ -722,7 +722,7 @@ class PgvectorClient(VectorDBBase):
|
|||
try:
|
||||
deleted = self.session.query(DocumentChunk).delete()
|
||||
self.session.commit()
|
||||
log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.")
|
||||
log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f'Error during reset: {e}')
|
||||
|
|
@ -746,4 +746,4 @@ class PgvectorClient(VectorDBBase):
|
|||
|
||||
def delete_collection(self, collection_name: str) -> None:
|
||||
self.delete(collection_name)
|
||||
log.info(f"Collection '{collection_name}' deleted.")
|
||||
log.info("Collection '%s' deleted.", collection_name)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import normalize_filter, process_metadata
|
||||
|
||||
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
||||
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
|
||||
|
|
@ -106,16 +106,16 @@ class PineconeClient(VectorDBBase):
|
|||
try:
|
||||
# Check if index exists
|
||||
if self.index_name not in self.client.list_indexes().names():
|
||||
log.info(f"Creating Pinecone index '{self.index_name}'...")
|
||||
log.info("Creating Pinecone index '%s'...", self.index_name)
|
||||
self.client.create_index(
|
||||
name=self.index_name,
|
||||
dimension=self.dimension,
|
||||
metric=self.metric,
|
||||
spec=ServerlessSpec(cloud=self.cloud, region=self.environment),
|
||||
)
|
||||
log.info(f"Successfully created Pinecone index '{self.index_name}'")
|
||||
log.info("Successfully created Pinecone index '%s'", self.index_name)
|
||||
else:
|
||||
log.info(f"Using existing Pinecone index '{self.index_name}'")
|
||||
log.info("Using existing Pinecone index '%s'", self.index_name)
|
||||
|
||||
# Connect to the index
|
||||
self.index = self.client.Index(
|
||||
|
|
@ -245,7 +245,7 @@ class PineconeClient(VectorDBBase):
|
|||
collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name)
|
||||
try:
|
||||
self.index.delete(filter={'collection_name': collection_name_with_prefix})
|
||||
log.info(f"Collection '{collection_name_with_prefix}' deleted (all vectors removed).")
|
||||
log.info("Collection '%s' deleted (all vectors removed).", collection_name_with_prefix)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to delete collection '{collection_name_with_prefix}': {e}")
|
||||
raise
|
||||
|
|
@ -274,9 +274,9 @@ class PineconeClient(VectorDBBase):
|
|||
log.error(f'Error inserting batch: {e}')
|
||||
raise
|
||||
elapsed = time.time() - start_time
|
||||
log.debug(f'Insert of {len(points)} vectors took {elapsed:.2f} seconds')
|
||||
log.debug('Insert of %s vectors took %.2f seconds', len(points), elapsed)
|
||||
log.info(
|
||||
f"Successfully inserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'"
|
||||
"Successfully inserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix
|
||||
)
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
|
|
@ -303,9 +303,9 @@ class PineconeClient(VectorDBBase):
|
|||
log.error(f'Error upserting batch: {e}')
|
||||
raise
|
||||
elapsed = time.time() - start_time
|
||||
log.debug(f'Upsert of {len(points)} vectors took {elapsed:.2f} seconds')
|
||||
log.debug('Upsert of %s vectors took %.2f seconds', len(points), elapsed)
|
||||
log.info(
|
||||
f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'"
|
||||
"Successfully upserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix
|
||||
)
|
||||
|
||||
async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
|
|
@ -326,7 +326,9 @@ class PineconeClient(VectorDBBase):
|
|||
if isinstance(result, Exception):
|
||||
log.error(f'Error in async insert batch: {result}')
|
||||
raise result
|
||||
log.info(f"Successfully async inserted {len(points)} vectors in batches into '{collection_name_with_prefix}'")
|
||||
log.info(
|
||||
"Successfully async inserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix
|
||||
)
|
||||
|
||||
async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""Async version of upsert using asyncio and run_in_executor for improved performance."""
|
||||
|
|
@ -346,7 +348,9 @@ class PineconeClient(VectorDBBase):
|
|||
if isinstance(result, Exception):
|
||||
log.error(f'Error in async upsert batch: {result}')
|
||||
raise result
|
||||
log.info(f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'")
|
||||
log.info(
|
||||
"Successfully async upserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
|
|
@ -368,13 +372,15 @@ class PineconeClient(VectorDBBase):
|
|||
try:
|
||||
# Search using the first vector (assuming this is the intended behavior)
|
||||
query_vector = vectors[0]
|
||||
pinecone_filter = normalize_filter(filter)
|
||||
pinecone_filter['collection_name'] = collection_name_with_prefix
|
||||
|
||||
# Perform the search
|
||||
query_response = self.index.query(
|
||||
vector=query_vector,
|
||||
top_k=limit,
|
||||
include_metadata=True,
|
||||
filter={'collection_name': collection_name_with_prefix},
|
||||
filter=pinecone_filter,
|
||||
)
|
||||
|
||||
matches = getattr(query_response, 'matches', []) or []
|
||||
|
|
@ -474,8 +480,10 @@ class PineconeClient(VectorDBBase):
|
|||
# Note: When deleting by ID, we can't filter by collection_name
|
||||
# This is a limitation of Pinecone - be careful with ID uniqueness
|
||||
self.index.delete(ids=batch_ids)
|
||||
log.debug(f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'")
|
||||
log.info(f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'")
|
||||
log.debug(
|
||||
"Deleted batch of %s vectors by ID from '%s'", len(batch_ids), collection_name_with_prefix
|
||||
)
|
||||
log.info("Successfully deleted %s vectors by ID from '%s'", len(ids), collection_name_with_prefix)
|
||||
|
||||
elif filter:
|
||||
# Combine user filter with collection_name
|
||||
|
|
@ -484,7 +492,7 @@ class PineconeClient(VectorDBBase):
|
|||
pinecone_filter.update(filter)
|
||||
# Delete by metadata filter
|
||||
self.index.delete(filter=pinecone_filter)
|
||||
log.info(f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'")
|
||||
log.info("Successfully deleted vectors by filter from '%s'", collection_name_with_prefix)
|
||||
|
||||
else:
|
||||
log.warning('No ids or filter provided for delete operation')
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on
|
|||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from open_webui.config import (
|
||||
|
|
@ -22,6 +22,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions
|
||||
from qdrant_client import QdrantClient as Qclient
|
||||
from qdrant_client.http.models import PointStruct
|
||||
from qdrant_client.models import models
|
||||
|
|
@ -31,6 +32,11 @@ NO_LIMIT = 999999999
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _metadata_filter(key: str, op: str, value: Any) -> models.FieldCondition:
|
||||
match = models.MatchAny(any=value) if op == '$in' else models.MatchValue(value=value)
|
||||
return models.FieldCondition(key=f'metadata.{key}', match=match)
|
||||
|
||||
|
||||
class QdrantClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||
|
|
@ -119,7 +125,7 @@ class QdrantClient(VectorDBBase):
|
|||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
)
|
||||
log.info(f'collection {collection_name_with_prefix} successfully created!')
|
||||
log.info('collection %s successfully created!', collection_name_with_prefix)
|
||||
|
||||
def _create_collection_if_not_exists(self, collection_name, dimension):
|
||||
if not self.has_collection(collection_name=collection_name):
|
||||
|
|
@ -152,10 +158,13 @@ class QdrantClient(VectorDBBase):
|
|||
if limit is None:
|
||||
limit = NO_LIMIT # otherwise qdrant would set limit to 10!
|
||||
|
||||
conditions = [_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)]
|
||||
query_filter = models.Filter(must=conditions) if conditions else None
|
||||
query_response = self.client.query_points(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
query=vectors[0],
|
||||
limit=limit,
|
||||
query_filter=query_filter,
|
||||
)
|
||||
get_result = self._result_to_get_result(query_response.points)
|
||||
return SearchResult(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions
|
||||
from qdrant_client import QdrantClient as Qclient
|
||||
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||
from qdrant_client.http.models import PointStruct
|
||||
|
|
@ -39,8 +40,9 @@ def _tenant_filter(tenant_id: str) -> models.FieldCondition:
|
|||
return models.FieldCondition(key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id))
|
||||
|
||||
|
||||
def _metadata_filter(key: str, value: Any) -> models.FieldCondition:
|
||||
return models.FieldCondition(key=f'metadata.{key}', match=models.MatchValue(value=value))
|
||||
def _metadata_filter(key: str, op: str, value: Any) -> models.FieldCondition:
|
||||
match = models.MatchAny(any=value) if op == '$in' else models.MatchValue(value=value)
|
||||
return models.FieldCondition(key=f'metadata.{key}', match=match)
|
||||
|
||||
|
||||
class QdrantClient(VectorDBBase):
|
||||
|
|
@ -148,7 +150,7 @@ class QdrantClient(VectorDBBase):
|
|||
m=0,
|
||||
),
|
||||
)
|
||||
log.info(f'Multi-tenant collection {mt_collection_name} created with dimension {dimension}!')
|
||||
log.info('Multi-tenant collection %s created with dimension %s!', mt_collection_name, dimension)
|
||||
|
||||
self.client.create_payload_index(
|
||||
collection_name=mt_collection_name,
|
||||
|
|
@ -224,7 +226,7 @@ class QdrantClient(VectorDBBase):
|
|||
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||
log.debug("Collection %s doesn't exist, nothing to delete", mt_collection)
|
||||
return None
|
||||
|
||||
must_conditions = [_tenant_filter(tenant_id)]
|
||||
|
|
@ -234,7 +236,7 @@ class QdrantClient(VectorDBBase):
|
|||
# whose payload omits an id (e.g. memories), leaving orphaned vectors.
|
||||
must_conditions.append(models.HasIdCondition(has_id=ids))
|
||||
elif filter:
|
||||
must_conditions += [_metadata_filter(k, v) for k, v in filter.items()]
|
||||
must_conditions += [_metadata_filter(k, '$eq', v) for k, v in filter.items()]
|
||||
|
||||
return self.client.delete(
|
||||
collection_name=mt_collection,
|
||||
|
|
@ -255,15 +257,17 @@ class QdrantClient(VectorDBBase):
|
|||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, search returns None")
|
||||
log.debug("Collection %s doesn't exist, search returns None", mt_collection)
|
||||
return None
|
||||
|
||||
tenant_filter = _tenant_filter(tenant_id)
|
||||
conditions = [_tenant_filter(tenant_id)]
|
||||
if filter:
|
||||
conditions.extend(_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter))
|
||||
query_response = self.client.query_points(
|
||||
collection_name=mt_collection,
|
||||
query=vectors[0],
|
||||
limit=limit,
|
||||
query_filter=models.Filter(must=[tenant_filter]),
|
||||
query_filter=models.Filter(must=conditions),
|
||||
)
|
||||
get_result = self._result_to_get_result(query_response.points)
|
||||
return SearchResult(
|
||||
|
|
@ -281,12 +285,12 @@ class QdrantClient(VectorDBBase):
|
|||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, query returns None")
|
||||
log.debug("Collection %s doesn't exist, query returns None", mt_collection)
|
||||
return None
|
||||
if limit is None:
|
||||
limit = NO_LIMIT
|
||||
tenant_filter = _tenant_filter(tenant_id)
|
||||
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
|
||||
field_conditions = [_metadata_filter(k, '$eq', v) for k, v in filter.items()]
|
||||
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
|
||||
points = self.client.scroll(
|
||||
collection_name=mt_collection,
|
||||
|
|
@ -303,7 +307,7 @@ class QdrantClient(VectorDBBase):
|
|||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
|
||||
log.debug("Collection %s doesn't exist, get returns None", mt_collection)
|
||||
return None
|
||||
tenant_filter = _tenant_filter(tenant_id)
|
||||
points = self.client.scroll(
|
||||
|
|
@ -350,7 +354,7 @@ class QdrantClient(VectorDBBase):
|
|||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||
log.debug("Collection %s doesn't exist, nothing to delete", mt_collection)
|
||||
return None
|
||||
self.client.delete(
|
||||
collection_name=mt_collection,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import metadata_matches_filter, normalize_filter, process_metadata
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ class S3VectorClient(VectorDBBase):
|
|||
if self.bucket_name and self.region:
|
||||
try:
|
||||
self.client = boto3.client('s3vectors', region_name=self.region)
|
||||
log.info(f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'")
|
||||
log.info("S3Vector client initialized for bucket '%s' in region '%s'", self.bucket_name, self.region)
|
||||
except Exception as e:
|
||||
log.error(f'Failed to initialize S3Vector client: {e}')
|
||||
self.client = None
|
||||
|
|
@ -54,7 +54,7 @@ class S3VectorClient(VectorDBBase):
|
|||
Create a new index in the S3 vector bucket for the given collection if it does not exist.
|
||||
"""
|
||||
if self.has_collection(index_name):
|
||||
log.debug(f"Index '{index_name}' already exists, skipping creation")
|
||||
log.debug("Index '%s' already exists, skipping creation", index_name)
|
||||
return
|
||||
|
||||
try:
|
||||
|
|
@ -70,7 +70,9 @@ class S3VectorClient(VectorDBBase):
|
|||
]
|
||||
},
|
||||
)
|
||||
log.info(f'Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})')
|
||||
log.info(
|
||||
'Created S3 index: %s (dim=%s, type=%s, metric=%s)', index_name, dimension, data_type, distance_metric
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error creating S3 index '{index_name}': {e}")
|
||||
raise
|
||||
|
|
@ -137,9 +139,9 @@ class S3VectorClient(VectorDBBase):
|
|||
return
|
||||
|
||||
try:
|
||||
log.info(f"Deleting collection '{collection_name}'")
|
||||
log.info("Deleting collection '%s'", collection_name)
|
||||
self.client.delete_index(vectorBucketName=self.bucket_name, indexName=collection_name)
|
||||
log.info(f"Successfully deleted collection '{collection_name}'")
|
||||
log.info("Successfully deleted collection '%s'", collection_name)
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting collection '{collection_name}': {e}")
|
||||
raise
|
||||
|
|
@ -156,7 +158,7 @@ class S3VectorClient(VectorDBBase):
|
|||
|
||||
try:
|
||||
if not self.has_collection(collection_name):
|
||||
log.info(f"Index '{collection_name}' does not exist. Creating index.")
|
||||
log.info("Index '%s' does not exist. Creating index.", collection_name)
|
||||
self._create_index(
|
||||
index_name=collection_name,
|
||||
dimension=dimension,
|
||||
|
|
@ -202,9 +204,11 @@ class S3VectorClient(VectorDBBase):
|
|||
indexName=collection_name,
|
||||
vectors=batch,
|
||||
)
|
||||
log.info(f"Inserted batch {i // batch_size + 1}: {len(batch)} vectors into index '{collection_name}'.")
|
||||
log.info(
|
||||
"Inserted batch %s: %s vectors into index '%s'.", i // batch_size + 1, len(batch), collection_name
|
||||
)
|
||||
|
||||
log.info(f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'.")
|
||||
log.info("Completed insertion of %s vectors into index '%s'.", len(vectors), collection_name)
|
||||
except Exception as e:
|
||||
log.error(f'Error inserting vectors: {e}')
|
||||
raise
|
||||
|
|
@ -218,11 +222,11 @@ class S3VectorClient(VectorDBBase):
|
|||
return
|
||||
|
||||
dimension = len(items[0]['vector'])
|
||||
log.info(f'Upsert dimension: {dimension}')
|
||||
log.info('Upsert dimension: %s', dimension)
|
||||
|
||||
try:
|
||||
if not self.has_collection(collection_name):
|
||||
log.info(f"Index '{collection_name}' does not exist. Creating index for upsert.")
|
||||
log.info("Index '%s' does not exist. Creating index for upsert.", collection_name)
|
||||
self._create_index(
|
||||
index_name=collection_name,
|
||||
dimension=dimension,
|
||||
|
|
@ -264,10 +268,14 @@ class S3VectorClient(VectorDBBase):
|
|||
batch = vectors[i : i + batch_size]
|
||||
if i == 0: # Log sample info for first batch only
|
||||
log.info(
|
||||
f'Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]["key"]}, data_type={type(batch[0]["data"]["float32"])}, data_len={len(batch[0]["data"]["float32"])}'
|
||||
'Upserting batch 1: %s vectors. First vector sample: key=%s, data_type=%s, data_len=%s',
|
||||
len(batch),
|
||||
batch[0]['key'],
|
||||
type(batch[0]['data']['float32']),
|
||||
len(batch[0]['data']['float32']),
|
||||
)
|
||||
else:
|
||||
log.info(f'Upserting batch {i // batch_size + 1}: {len(batch)} vectors.')
|
||||
log.info('Upserting batch %s: %s vectors.', i // batch_size + 1, len(batch))
|
||||
|
||||
self.client.put_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
|
|
@ -275,7 +283,7 @@ class S3VectorClient(VectorDBBase):
|
|||
vectors=batch,
|
||||
)
|
||||
|
||||
log.info(f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'.")
|
||||
log.info("Completed upsert of %s vectors into index '%s'.", len(vectors), collection_name)
|
||||
except Exception as e:
|
||||
log.error(f'Error upserting vectors: {e}')
|
||||
raise
|
||||
|
|
@ -300,7 +308,8 @@ class S3VectorClient(VectorDBBase):
|
|||
return None
|
||||
|
||||
try:
|
||||
log.info(f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}")
|
||||
log.info("Searching collection '%s' with %s query vectors, limit=%s", collection_name, len(vectors), limit)
|
||||
vector_filter = normalize_filter(filter)
|
||||
|
||||
# Initialize result lists
|
||||
all_ids = []
|
||||
|
|
@ -310,20 +319,23 @@ class S3VectorClient(VectorDBBase):
|
|||
|
||||
# Process each query vector
|
||||
for i, query_vector in enumerate(vectors):
|
||||
log.debug(f'Processing query vector {i + 1}/{len(vectors)}')
|
||||
log.debug('Processing query vector %s/%s', i + 1, len(vectors))
|
||||
|
||||
# Prepare the query vector in S3 Vector format
|
||||
query_vector_dict = {'float32': [float(x) for x in query_vector]}
|
||||
|
||||
# Call S3 Vector query API
|
||||
response = self.client.query_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
topK=limit,
|
||||
queryVector=query_vector_dict,
|
||||
returnMetadata=True,
|
||||
returnDistance=True,
|
||||
)
|
||||
request_params = {
|
||||
'vectorBucketName': self.bucket_name,
|
||||
'indexName': collection_name,
|
||||
'topK': limit,
|
||||
'queryVector': query_vector_dict,
|
||||
'returnMetadata': True,
|
||||
'returnDistance': True,
|
||||
}
|
||||
if vector_filter:
|
||||
request_params['filter'] = vector_filter
|
||||
|
||||
response = self.client.query_vectors(**request_params)
|
||||
|
||||
# Process results for this query
|
||||
query_ids = []
|
||||
|
|
@ -338,6 +350,9 @@ class S3VectorClient(VectorDBBase):
|
|||
vector_metadata = vector.get('metadata', {})
|
||||
vector_distance = vector.get('distance', 0.0)
|
||||
|
||||
if vector_filter and not metadata_matches_filter(vector_metadata, vector_filter):
|
||||
continue
|
||||
|
||||
# Extract document text from metadata
|
||||
document_text = ''
|
||||
if isinstance(vector_metadata, dict):
|
||||
|
|
@ -362,7 +377,7 @@ class S3VectorClient(VectorDBBase):
|
|||
all_metadatas.append(query_metadatas)
|
||||
all_distances.append(query_distances)
|
||||
|
||||
log.info(f'Search completed. Found results for {len(all_ids)} queries')
|
||||
log.info('Search completed. Found results for %s queries', len(all_ids))
|
||||
|
||||
# Return SearchResult format
|
||||
return SearchResult(
|
||||
|
|
@ -402,7 +417,7 @@ class S3VectorClient(VectorDBBase):
|
|||
return self.get(collection_name)
|
||||
|
||||
try:
|
||||
log.info(f"Querying collection '{collection_name}' with filter: {filter}")
|
||||
log.info("Querying collection '%s' with filter: %s", collection_name, filter)
|
||||
|
||||
# For S3 Vector, we need to use list_vectors and then filter results
|
||||
# Since S3 Vector may not support complex server-side filtering,
|
||||
|
|
@ -437,7 +452,7 @@ class S3VectorClient(VectorDBBase):
|
|||
if limit and len(filtered_ids) >= limit:
|
||||
break
|
||||
|
||||
log.info(f'Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total')
|
||||
log.info('Filter applied: %s vectors match out of %s total', len(filtered_ids), len(all_ids))
|
||||
|
||||
# Return GetResult format
|
||||
if filtered_ids:
|
||||
|
|
@ -472,7 +487,7 @@ class S3VectorClient(VectorDBBase):
|
|||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
|
||||
try:
|
||||
log.info(f"Retrieving all vectors from collection '{collection_name}'")
|
||||
log.info("Retrieving all vectors from collection '%s'", collection_name)
|
||||
|
||||
# Initialize result lists
|
||||
all_ids = []
|
||||
|
|
@ -521,7 +536,7 @@ class S3VectorClient(VectorDBBase):
|
|||
)
|
||||
|
||||
# Log the actual content for debugging
|
||||
log.debug(f'Document text preview (first 200 chars): {str(document_text)[:200]}')
|
||||
log.debug('Document text preview (first 200 chars): %s', str(document_text)[:200])
|
||||
else:
|
||||
document_text = vector_id
|
||||
|
||||
|
|
@ -534,7 +549,7 @@ class S3VectorClient(VectorDBBase):
|
|||
if not next_token:
|
||||
break
|
||||
|
||||
log.info(f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'")
|
||||
log.info("Retrieved %s vectors from collection '%s'", len(all_ids), collection_name)
|
||||
|
||||
# Return in GetResult format
|
||||
# The Open WebUI GetResult expects lists of lists, so we wrap each list
|
||||
|
|
@ -576,17 +591,17 @@ class S3VectorClient(VectorDBBase):
|
|||
try:
|
||||
if ids:
|
||||
# Delete by specific vector IDs/keys
|
||||
log.info(f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'")
|
||||
log.info("Deleting %s vectors by IDs from collection '%s'", len(ids), collection_name)
|
||||
self.client.delete_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
keys=ids,
|
||||
)
|
||||
log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'")
|
||||
log.info("Deleted %s vectors from index '%s'", len(ids), collection_name)
|
||||
|
||||
elif filter:
|
||||
# Handle filter-based deletion
|
||||
log.info(f"Deleting vectors by filter from collection '{collection_name}': {filter}")
|
||||
log.info("Deleting vectors by filter from collection '%s': %s", collection_name, filter)
|
||||
|
||||
# If this is a knowledge collection and we have a file_id filter,
|
||||
# also clean up the corresponding file-specific collection
|
||||
|
|
@ -595,7 +610,8 @@ class S3VectorClient(VectorDBBase):
|
|||
file_collection_name = f'file-{file_id}'
|
||||
if self.has_collection(file_collection_name):
|
||||
log.info(
|
||||
f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates"
|
||||
"Found related file-specific collection '%s', deleting it to prevent duplicates",
|
||||
file_collection_name,
|
||||
)
|
||||
self.delete_collection(file_collection_name)
|
||||
|
||||
|
|
@ -604,7 +620,7 @@ class S3VectorClient(VectorDBBase):
|
|||
query_result = self.query(collection_name, filter)
|
||||
if query_result and query_result.ids and query_result.ids[0]:
|
||||
matching_ids = query_result.ids[0]
|
||||
log.info(f'Found {len(matching_ids)} vectors matching filter, deleting them')
|
||||
log.info('Found %s vectors matching filter, deleting them', len(matching_ids))
|
||||
|
||||
# Delete the matching vectors by ID
|
||||
self.client.delete_vectors(
|
||||
|
|
@ -612,7 +628,7 @@ class S3VectorClient(VectorDBBase):
|
|||
indexName=collection_name,
|
||||
keys=matching_ids,
|
||||
)
|
||||
log.info(f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter")
|
||||
log.info("Deleted %s vectors from index '%s' using filter", len(matching_ids), collection_name)
|
||||
else:
|
||||
log.warning('No vectors found matching the filter criteria')
|
||||
else:
|
||||
|
|
@ -645,11 +661,11 @@ class S3VectorClient(VectorDBBase):
|
|||
try:
|
||||
self.client.delete_index(vectorBucketName=self.bucket_name, indexName=index_name)
|
||||
deleted_count += 1
|
||||
log.info(f'Deleted index: {index_name}')
|
||||
log.info('Deleted index: %s', index_name)
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting index '{index_name}': {e}")
|
||||
|
||||
log.info(f'Reset completed: deleted {deleted_count} indexes')
|
||||
log.info('Reset completed: deleted %s indexes', deleted_count)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'Error during reset: {e}')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Requires Valkey core >= 9.0.1 with the valkey-search module >= 1.2.0 loaded.
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
|
|
@ -24,6 +23,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -279,7 +279,7 @@ class ValkeyClient(VectorDBBase):
|
|||
f'{self._format_version(MIN_VALKEY_VERSION)}. valkey-search 1.2.0 requires Valkey core '
|
||||
'9.0.1 or later. Upgrade your server or use valkey-bundle:9.1.0-rc2+.'
|
||||
)
|
||||
log.info(f'Valkey core version: {self._format_version(version) if version else "unknown"}')
|
||||
log.info('Valkey core version: %s', self._format_version(version) if version else 'unknown')
|
||||
|
||||
def _check_search_module(self) -> None:
|
||||
try:
|
||||
|
|
@ -331,7 +331,7 @@ class ValkeyClient(VectorDBBase):
|
|||
'TEXT field type and filter-only FT.SEARCH support required by this backend. '
|
||||
'Upgrade to valkey-bundle:9.1.0-rc2+ or load valkey-search 1.2.0+ as a module.'
|
||||
)
|
||||
log.info(f'valkey-search version: {self._format_version(search_version) if search_version else "unknown"}')
|
||||
log.info('valkey-search version: %s', self._format_version(search_version) if search_version else 'unknown')
|
||||
|
||||
def _index_name(self, collection_name: str) -> str:
|
||||
return f'idx:{self.collection_prefix}:{collection_name}'
|
||||
|
|
@ -385,12 +385,15 @@ class ValkeyClient(VectorDBBase):
|
|||
try:
|
||||
g['glide_ft'].create(self.client, index_name, schema, options)
|
||||
log.info(
|
||||
f'Created Valkey index {index_name} with dimension={dimension}, '
|
||||
f'type={self.index_type}, metric={self.distance_metric}'
|
||||
'Created Valkey index %s with dimension=%s, type=%s, metric=%s',
|
||||
index_name,
|
||||
dimension,
|
||||
self.index_type,
|
||||
self.distance_metric,
|
||||
)
|
||||
except g['RequestError'] as e:
|
||||
if 'already exists' in str(e).lower():
|
||||
log.debug(f'Index {index_name} already exists, skipping creation.')
|
||||
log.debug('Index %s already exists, skipping creation.', index_name)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
|
@ -456,9 +459,9 @@ class ValkeyClient(VectorDBBase):
|
|||
index_name = self._index_name(collection_name)
|
||||
try:
|
||||
self._g['glide_ft'].dropindex(self.client, index_name)
|
||||
log.info(f'Dropped index {index_name}')
|
||||
log.info('Dropped index %s', index_name)
|
||||
except self._g['RequestError'] as e:
|
||||
log.debug(f'Could not drop index {index_name}: {e}')
|
||||
log.debug('Could not drop index %s: %s', index_name, e)
|
||||
|
||||
self._delete_keys_by_prefix(self._key_prefix(collection_name))
|
||||
|
||||
|
|
@ -482,7 +485,7 @@ class ValkeyClient(VectorDBBase):
|
|||
'id': item['id'],
|
||||
'vector': _vector_to_bytes(item['vector']),
|
||||
'text': item['text'],
|
||||
'metadata_json': json.dumps(metadata),
|
||||
'metadata_json': JSONCodec.dumps(metadata),
|
||||
# `or ''` prevents indexing literal 'None' as a TAG value, which would
|
||||
# poison $ne / equality queries.
|
||||
'hash': str(metadata.get('hash') or ''),
|
||||
|
|
@ -492,7 +495,7 @@ class ValkeyClient(VectorDBBase):
|
|||
}
|
||||
self.batch_client.hset(self._item_key(collection_name, item['id']), mapping)
|
||||
|
||||
log.debug(f'Inserted {len(items)} items into collection {collection_name}')
|
||||
log.debug('Inserted %s items into collection %s', len(items), collection_name)
|
||||
|
||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||
self.insert(collection_name, items)
|
||||
|
|
@ -588,8 +591,8 @@ class ValkeyClient(VectorDBBase):
|
|||
ids.append(_decode(fields.get(b'id', b'')))
|
||||
documents.append(_decode(fields.get(b'text', b'')))
|
||||
try:
|
||||
metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}'))))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}'))))
|
||||
except (ValueError, TypeError):
|
||||
metadatas.append({})
|
||||
if limit is not None and limit > 0 and len(ids) >= limit:
|
||||
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||
|
|
@ -656,7 +659,7 @@ class ValkeyClient(VectorDBBase):
|
|||
collections.append(name[len(idx_prefix) :])
|
||||
try:
|
||||
glide_ft.dropindex(self.client, idx)
|
||||
log.info(f'Dropped index: {name}')
|
||||
log.info('Dropped index: %s', name)
|
||||
except Exception as e:
|
||||
log.error(f'Error dropping index {name}: {e}')
|
||||
except Exception as e:
|
||||
|
|
@ -664,7 +667,7 @@ class ValkeyClient(VectorDBBase):
|
|||
|
||||
for collection in collections:
|
||||
self._delete_keys_by_prefix(self._key_prefix(collection))
|
||||
log.info(f'Valkey vector store reset complete (prefix: {self.collection_prefix})')
|
||||
log.info('Valkey vector store reset complete (prefix: %s)', self.collection_prefix)
|
||||
|
||||
def _delete_keys_by_prefix(self, prefix: str) -> None:
|
||||
cursor = '0'
|
||||
|
|
@ -734,8 +737,8 @@ class ValkeyClient(VectorDBBase):
|
|||
ids.append(_decode(fields.get(b'id', b'')))
|
||||
documents.append(_decode(fields.get(b'text', b'')))
|
||||
try:
|
||||
metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}'))))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}'))))
|
||||
except (ValueError, TypeError):
|
||||
metadatas.append({})
|
||||
|
||||
if include_score:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
|
||||
|
||||
|
||||
def _convert_uuids_to_strings(obj: Any) -> Any:
|
||||
|
|
@ -54,6 +54,20 @@ def _convert_uuids_to_strings(obj: Any) -> Any:
|
|||
return obj
|
||||
|
||||
|
||||
def _metadata_filter(filter: Optional[dict]) -> Any:
|
||||
clauses = []
|
||||
for key, op, value in iter_filter_conditions(filter):
|
||||
if op == '$in':
|
||||
clauses.append(
|
||||
weaviate.classes.query.Filter.any_of(
|
||||
[weaviate.classes.query.Filter.by_property(name=key).equal(item) for item in value]
|
||||
)
|
||||
)
|
||||
else:
|
||||
clauses.append(weaviate.classes.query.Filter.by_property(name=key).equal(value))
|
||||
return weaviate.classes.query.Filter.all_of(clauses) if len(clauses) > 1 else (clauses[0] if clauses else None)
|
||||
|
||||
|
||||
class WeaviateClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.url = WEAVIATE_HTTP_HOST
|
||||
|
|
@ -168,6 +182,7 @@ class WeaviateClient(VectorDBBase):
|
|||
return None
|
||||
|
||||
collection = self.client.collections.get(sane_collection_name)
|
||||
weaviate_filter = _metadata_filter(filter)
|
||||
|
||||
result_ids, result_documents, result_metadatas, result_distances = (
|
||||
[],
|
||||
|
|
@ -181,6 +196,7 @@ class WeaviateClient(VectorDBBase):
|
|||
response = collection.query.near_vector(
|
||||
near_vector=vector_embedding,
|
||||
limit=limit,
|
||||
filters=weaviate_filter,
|
||||
return_metadata=weaviate.classes.query.MetadataQuery(distance=True),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from open_webui.env import RAG_METADATA_MAX_VALUE_CHARS
|
||||
from open_webui.retrieval.vector.main import SearchResult
|
||||
from open_webui.utils.misc import sanitize_text_for_db
|
||||
|
||||
KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures']
|
||||
KEYS_TO_EXCLUDE = [
|
||||
'content',
|
||||
'pages',
|
||||
'tables',
|
||||
'paragraphs',
|
||||
'sections',
|
||||
'figures',
|
||||
'documents',
|
||||
'keyValuePairs',
|
||||
'styles',
|
||||
'languages',
|
||||
]
|
||||
|
||||
|
||||
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
|
||||
# Removes large/redundant fields from metadata dict.
|
||||
metadata = {key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE}
|
||||
return metadata
|
||||
result = {}
|
||||
for key, value in metadata.items():
|
||||
if key in KEYS_TO_EXCLUDE:
|
||||
continue
|
||||
if RAG_METADATA_MAX_VALUE_CHARS is not None and isinstance(value, (list, dict)):
|
||||
try:
|
||||
if len(str(value)) > RAG_METADATA_MAX_VALUE_CHARS:
|
||||
continue
|
||||
except (MemoryError, RecursionError, ValueError):
|
||||
continue
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def process_metadata(
|
||||
|
|
@ -25,6 +47,12 @@ def process_metadata(
|
|||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if RAG_METADATA_MAX_VALUE_CHARS is not None and isinstance(value, (list, dict)):
|
||||
try:
|
||||
if len(str(value)) > RAG_METADATA_MAX_VALUE_CHARS:
|
||||
continue
|
||||
except (MemoryError, RecursionError, ValueError):
|
||||
continue
|
||||
# Convert non-serializable fields to strings
|
||||
if isinstance(value, (dt.datetime, list, dict)):
|
||||
result[key] = sanitize_text_for_db(str(value))
|
||||
|
|
@ -33,6 +61,33 @@ def process_metadata(
|
|||
return result
|
||||
|
||||
|
||||
def iter_filter_conditions(filter: dict[str, Any] | None):
|
||||
for key, value in (filter or {}).items():
|
||||
if isinstance(value, dict):
|
||||
if set(value) != {'$in'}:
|
||||
raise ValueError(f"Unsupported metadata filter for '{key}': {value}")
|
||||
yield key, '$in', list(value['$in'])
|
||||
else:
|
||||
yield key, '$eq', value
|
||||
|
||||
|
||||
def normalize_filter(filter: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {key: {'$in': value} if op == '$in' else value for key, op, value in iter_filter_conditions(filter)}
|
||||
|
||||
|
||||
def metadata_matches_filter(metadata: dict[str, Any], filter: dict[str, Any] | None) -> bool:
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
for key, op, value in iter_filter_conditions(filter):
|
||||
actual = metadata.get(key)
|
||||
if op == '$in':
|
||||
if actual not in value:
|
||||
return False
|
||||
elif actual != value:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def merge_hybrid_search_results(
|
||||
vector_result: SearchResult | None,
|
||||
fts_results: list[dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ def search_bocha(api_key: str, query: str, count: int, filter_list: Optional[lis
|
|||
url = 'https://api.bochaai.com/v1/web-search?utm_source=ollama'
|
||||
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
|
||||
|
||||
payload = json.dumps({'query': query, 'summary': True, 'freshness': 'noLimit', 'count': count})
|
||||
payload = JSONCodec.dumps({'query': query, 'summary': True, 'freshness': 'noLimit', 'count': count})
|
||||
|
||||
response = requests.post(url, headers=headers, data=payload, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import logging
|
|||
import urllib.request
|
||||
|
||||
from ddgs import DDGS
|
||||
from ddgs.exceptions import RatelimitException
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -31,21 +30,11 @@ def search_duckduckgo(
|
|||
# Resolve via stdlib getproxies() — same pattern as the other loaders.
|
||||
env_proxies = urllib.request.getproxies()
|
||||
proxy = env_proxies.get('https') or env_proxies.get('http')
|
||||
search_results = []
|
||||
with DDGS(proxy=proxy) as ddgs:
|
||||
if concurrent_requests:
|
||||
ddgs.threads = concurrent_requests
|
||||
|
||||
# Use the ddgs.text() method to perform the search
|
||||
try:
|
||||
kwargs = {'safesearch': 'moderate', 'max_results': count}
|
||||
if backend and backend != 'auto':
|
||||
kwargs['backend'] = backend
|
||||
results = ddgs.text(query, **kwargs)
|
||||
search_results = results if results is not None else []
|
||||
except RatelimitException as e:
|
||||
log.error(f'RatelimitException: {e}')
|
||||
search_results = []
|
||||
search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend or 'auto')
|
||||
if filter_list:
|
||||
search_results = get_filtered_results(search_results, filter_list)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def search_exa(
|
|||
count (int): Number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results by
|
||||
"""
|
||||
log.info(f'Searching with Exa for query: {query}')
|
||||
log.info('Searching with Exa for query: %s', query)
|
||||
|
||||
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ def search_exa(
|
|||
)
|
||||
)
|
||||
|
||||
log.info(f'Found {len(results)} results')
|
||||
log.info('Found %s results', len(results))
|
||||
return [
|
||||
SearchResult(
|
||||
link=result.url,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ def search_external(
|
|||
) -> List[SearchResult]:
|
||||
try:
|
||||
headers = {
|
||||
# LICENSE covers this Open WebUI user-agent identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot',
|
||||
'Authorization': f'Bearer {external_api_key}',
|
||||
}
|
||||
|
|
@ -50,7 +53,7 @@ def search_external(
|
|||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
log.info(f'External search results: {results}')
|
||||
log.info('External search results: %s', results)
|
||||
return results
|
||||
except Exception as e:
|
||||
log.error(f'Error in External search: {e}')
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ def search_firecrawl(
|
|||
)
|
||||
)
|
||||
|
||||
log.info(f'FireCrawl search results: {search_results}')
|
||||
log.info('FireCrawl search results: %s', search_results)
|
||||
return search_results
|
||||
except Exception as e:
|
||||
log.error(f'Error in FireCrawl search: {e}')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import validators
|
||||
from open_webui.retrieval.web.utils import resolve_hostname
|
||||
from open_webui.utils.misc import get_allow_block_lists, is_host_allowed
|
||||
from open_webui.utils.misc import as_network, get_allow_block_lists, is_host_allowed
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
|
@ -14,14 +13,8 @@ def get_filtered_results(results, filter_list):
|
|||
return results
|
||||
|
||||
allow_list, block_list = get_allow_block_lists(filter_list)
|
||||
resolve_ips = False
|
||||
for entry in allow_list + block_list:
|
||||
try:
|
||||
ipaddress.ip_address(entry)
|
||||
except ValueError:
|
||||
continue
|
||||
resolve_ips = True
|
||||
break
|
||||
# Only worth a lookup when an entry names an address, since a hostname entry matches by name.
|
||||
resolve_ips = any(as_network(entry) is not None for entry in allow_list + block_list)
|
||||
|
||||
filtered_results = []
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ from open_webui.utils.headers import include_user_info_headers
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3'
|
||||
|
||||
|
||||
def search_microsoft_web_iq(
|
||||
api_base_url: str,
|
||||
|
|
@ -22,7 +20,7 @@ def search_microsoft_web_iq(
|
|||
user=None,
|
||||
) -> list[SearchResult]:
|
||||
try:
|
||||
api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/')
|
||||
api_base_url = api_base_url.rstrip('/')
|
||||
headers = {
|
||||
'host': urlparse(api_base_url).netloc or 'api.microsoft.ai',
|
||||
'x-apikey': api_key,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ def search_ollama_cloud(
|
|||
count (int): Number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results by
|
||||
"""
|
||||
log.info(f'Searching with Ollama for query: {query}')
|
||||
log.info('Searching with Ollama for query: %s', query)
|
||||
|
||||
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
|
||||
payload = {'query': query, 'max_results': count}
|
||||
|
|
@ -34,7 +34,7 @@ def search_ollama_cloud(
|
|||
data = response.json()
|
||||
|
||||
results = data.get('results', [])
|
||||
log.info(f'Found {len(results)} results')
|
||||
log.info('Found %s results', len(results))
|
||||
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def search_searchapi(
|
|||
response = requests.request('GET', url)
|
||||
|
||||
json_response = response.json()
|
||||
log.info(f'results from searchapi search: {json_response}')
|
||||
log.info('results from searchapi search: %s', json_response)
|
||||
|
||||
results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0))
|
||||
if filter_list:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ log = logging.getLogger(__name__)
|
|||
|
||||
# SearXNG request headers — identifies the bot to instance operators.
|
||||
_SEARXNG_HEADERS = {
|
||||
# LICENSE covers this Open WebUI user-agent identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def search_serpapi(
|
|||
response = requests.request('GET', url)
|
||||
|
||||
json_response = response.json()
|
||||
log.info(f'results from serpapi search: {json_response}')
|
||||
log.info('results from serpapi search: %s', json_response)
|
||||
|
||||
results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0))
|
||||
if filter_list:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -23,7 +23,7 @@ async def search_serper(
|
|||
headers = {'X-API-KEY': api_key, 'Content-Type': 'application/json'}
|
||||
|
||||
session = await get_session()
|
||||
async with session.post(url, headers=headers, data=json.dumps({'q': query})) as response:
|
||||
async with session.post(url, headers=headers, data=JSONCodec.dumps({'q': query})) as response:
|
||||
response.raise_for_status()
|
||||
payload = await response.json()
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ def search_serply(
|
|||
response.raise_for_status()
|
||||
|
||||
json_response = response.json()
|
||||
log.info(f'results from serply search: {json_response}')
|
||||
log.info('results from serply search: %s', json_response)
|
||||
|
||||
results = sorted(json_response.get('results', []), key=lambda x: x.get('realPosition', 0))
|
||||
if filter_list:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -28,10 +28,11 @@ def search_sougou(
|
|||
http_profile.endpoint = 'tms.tencentcloudapi.com'
|
||||
client_profile = ClientProfile()
|
||||
client_profile.http_profile = http_profile
|
||||
params = json.dumps({'Query': query, 'Cnt': 20})
|
||||
params = JSONCodec.dumps({'Query': query, 'Cnt': 20})
|
||||
common_client = CommonClient('tms', '2020-12-29', cred, '', profile=client_profile)
|
||||
results = [
|
||||
json.loads(page) for page in common_client.call_json('SearchPro', json.loads(params))['Response']['Pages']
|
||||
JSONCodec.loads(page)
|
||||
for page in common_client.call_json('SearchPro', JSONCodec.loads(params))['Response']['Pages']
|
||||
]
|
||||
sorted_results = sorted(results, key=lambda x: x.get('scour', 0.0), reverse=True)
|
||||
if filter_list:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
import requests
|
||||
from open_webui.env import TAVILY_API_BASE_URL
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -25,7 +26,7 @@ def search_tavily(
|
|||
Returns:
|
||||
A list of SearchResult objects.
|
||||
"""
|
||||
url = 'https://api.tavily.com/search'
|
||||
url = f'{TAVILY_API_BASE_URL}/search'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import http.cookiejar
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
|
|
@ -11,17 +12,19 @@ from typing import (
|
|||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import aiohttp
|
||||
import aiohttp.resolver
|
||||
import certifi
|
||||
import requests
|
||||
import urllib3.connection
|
||||
import urllib3.connectionpool
|
||||
import validators
|
||||
|
|
@ -52,6 +55,7 @@ from open_webui.constants import ERROR_MESSAGES
|
|||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_SSL_CERT_FILE,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
USER_AGENT,
|
||||
)
|
||||
|
|
@ -59,7 +63,7 @@ from open_webui.retrieval.loaders.external_web import ExternalWebLoader
|
|||
from open_webui.retrieval.loaders.microsoft_web_iq import MicrosoftWebIQLoader
|
||||
from open_webui.retrieval.loaders.tavily import TavilyLoader
|
||||
from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url
|
||||
from open_webui.utils.misc import is_host_allowed
|
||||
from open_webui.utils.misc import is_host_allowed, is_host_blocked
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -75,12 +79,10 @@ def resolve_hostname(hostname):
|
|||
return ipv4_addresses, ipv6_addresses
|
||||
|
||||
|
||||
def _is_global_addr(ip: str) -> bool:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
if not addr.is_global:
|
||||
return False
|
||||
def _embedded_ipv4(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> list[ipaddress.IPv4Address]:
|
||||
"""The IPv4 addresses an IPv6 address carries: mapped, compatible, 6to4, teredo and NAT64."""
|
||||
if not isinstance(addr, ipaddress.IPv6Address):
|
||||
return True
|
||||
return []
|
||||
|
||||
embedded = []
|
||||
if addr.ipv4_mapped:
|
||||
|
|
@ -91,16 +93,37 @@ def _is_global_addr(ip: str) -> bool:
|
|||
embedded.extend(addr.teredo)
|
||||
|
||||
b = addr.packed
|
||||
if b[:12] == b'\x00' * 12:
|
||||
embedded.append(ipaddress.IPv4Address(b[12:]))
|
||||
elif b[:12] == b'\x00\x64\xff\x9b' + b'\x00' * 8:
|
||||
# Prefixes that put the address in the last four bytes: v4-compatible and NAT64 /96.
|
||||
if b[:12] in (b'\x00' * 12, b'\x00\x64\xff\x9b' + b'\x00' * 8):
|
||||
embedded.append(ipaddress.IPv4Address(b[12:]))
|
||||
elif b[:6] == b'\x00\x64\xff\x9b\x00\x01':
|
||||
if b[8] != 0:
|
||||
return False
|
||||
embedded.append(ipaddress.IPv4Address(bytes((b[6], b[7], b[9], b[10]))))
|
||||
|
||||
return all(ip.is_global for ip in embedded)
|
||||
return embedded
|
||||
|
||||
|
||||
def _assert_host_allowed(host: str | None) -> None:
|
||||
if WEB_FETCH_FILTER_LIST and not is_host_allowed(host, WEB_FETCH_FILTER_LIST):
|
||||
log.warning(f'Blocked by filter list: {host}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
|
||||
def _assert_addresses_allowed(addresses: Sequence[str]) -> None:
|
||||
# An IPv6 address can carry a blocked IPv4 address inside it, so judge both spellings.
|
||||
parsed = [ipaddress.ip_address(address) for address in addresses]
|
||||
candidates = [*parsed, *(ipv4 for address in parsed for ipv4 in _embedded_ipv4(address))]
|
||||
|
||||
# Block entries only: an allow entry names a host, so judging a resolved address against one
|
||||
# would reject every allow-listed host.
|
||||
if is_host_blocked([str(address) for address in candidates], WEB_FETCH_FILTER_LIST):
|
||||
log.warning(f'Blocked by filter list: {", ".join(str(address) for address in candidates)}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
for address in candidates:
|
||||
if not address.is_global:
|
||||
log.warning(f'Blocked non-global address: {address}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
|
||||
def validate_url(url: Union[str, Sequence[str]]):
|
||||
|
|
@ -123,24 +146,22 @@ def validate_url(url: Union[str, Sequence[str]]):
|
|||
log.warning(f'Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
# Blocklist check using unified filtering logic
|
||||
if WEB_FETCH_FILTER_LIST:
|
||||
# Match on the parsed hostname, not the full URL: a path component would
|
||||
# otherwise let any URL slip past a hostname-based block/allow entry.
|
||||
if not is_host_allowed(parsed_url.hostname, WEB_FETCH_FILTER_LIST):
|
||||
log.warning(f'URL blocked by filter list: {url}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
# Match on the parsed hostname, not the full URL: a path component would
|
||||
# otherwise let any URL slip past a hostname-based block/allow entry.
|
||||
_assert_host_allowed(parsed_url.hostname)
|
||||
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
# Local web fetch is disabled, filter out URLs that resolve to non-global IP addresses.
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
# Get IPv4 and IPv6 addresses
|
||||
try:
|
||||
ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)
|
||||
# Check if any of the resolved addresses are private
|
||||
# DNS rebinding is mitigated at the connection layer; see _SSRFSafeResolver / _SSRFSafeAdapter
|
||||
for ip in ipv4_addresses + ipv6_addresses:
|
||||
if not _is_global_addr(ip):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
except (socket.gaierror, UnicodeError) as e:
|
||||
# With local fetch on, a proxied deployment can carry names only the proxy resolves.
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
log.warning(f'Could not resolve host {parsed_url.hostname}: {e}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL) from None
|
||||
ipv4_addresses, ipv6_addresses = [], []
|
||||
|
||||
# A hostname match alone lets a DNS record point at a blocked address.
|
||||
# DNS rebinding is mitigated at the connection layer; see _SSRFSafeConnector / _SSRFSafeAdapter
|
||||
_assert_addresses_allowed(ipv4_addresses + ipv6_addresses)
|
||||
return True
|
||||
elif isinstance(url, Sequence):
|
||||
return all(validate_url(u) for u in url)
|
||||
|
|
@ -155,13 +176,13 @@ def safe_validate_urls(url: Sequence[str]) -> Sequence[str]:
|
|||
if validate_url(u):
|
||||
valid_urls.append(u)
|
||||
except Exception as e:
|
||||
log.debug(f'Invalid URL {u}: {str(e)}')
|
||||
log.debug('Invalid URL %s: %s', u, e)
|
||||
continue
|
||||
return valid_urls
|
||||
|
||||
|
||||
def _ssrf_safe_new_conn(self):
|
||||
"""Resolve DNS, validate all IPs are global, connect to validated IP.
|
||||
"""Resolve DNS, screen every resolved address, connect to one of them.
|
||||
|
||||
Replaces urllib3's _new_conn so the DNS lookup that feeds the actual TCP
|
||||
connect is the same one we validate — no second resolution, no rebinding
|
||||
|
|
@ -172,10 +193,7 @@ def _ssrf_safe_new_conn(self):
|
|||
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
|
||||
if not infos:
|
||||
raise OSError(f'getaddrinfo for {host!r} returned empty list')
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
for _, _, _, _, sa in infos:
|
||||
if not _is_global_addr(sa[0]):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
_assert_addresses_allowed([sa[0] for _, _, _, _, sa in infos])
|
||||
err = None
|
||||
for fam, typ, proto, _, sa in infos:
|
||||
sock = None
|
||||
|
|
@ -218,7 +236,7 @@ class _SafeHTTPSPool(urllib3.connectionpool.HTTPSConnectionPool):
|
|||
|
||||
|
||||
class _SSRFSafeAdapter(HTTPAdapter):
|
||||
"""requests transport adapter that validates resolved IPs at connect time."""
|
||||
"""requests adapter that rejects filter-listed request targets and non-global IPs at connect time."""
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
super().init_poolmanager(*args, **kwargs)
|
||||
|
|
@ -227,32 +245,82 @@ class _SSRFSafeAdapter(HTTPAdapter):
|
|||
'https': _SafeHTTPSPool,
|
||||
}
|
||||
|
||||
def send(self, request, *args, **kwargs):
|
||||
# Per request, not per connection: the connection layer sees the proxy.
|
||||
_assert_host_allowed(urllib.parse.urlparse(request.url).hostname)
|
||||
return super().send(request, *args, **kwargs)
|
||||
|
||||
class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
|
||||
"""aiohttp resolver that rejects non-global IPs unless local fetch is on."""
|
||||
|
||||
async def resolve(self, host, port=0, family=socket.AF_INET):
|
||||
results = await super().resolve(host, port, family)
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
for entry in results:
|
||||
if not _is_global_addr(entry['host']):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
class _SSRFSafeConnector(aiohttp.TCPConnector):
|
||||
"""Rejects filter-listed request targets, and non-global IPs on each new connection."""
|
||||
|
||||
async def connect(self, req, traces, timeout):
|
||||
# Per request, not per connection: _resolve_host sees the proxy and pooled reuse skips it.
|
||||
_assert_host_allowed(req.url.host)
|
||||
return await super().connect(req, traces, timeout)
|
||||
|
||||
async def _resolve_host(self, host, port, traces=None):
|
||||
# aiohttp answers IP-literal hosts itself without consulting a resolver.
|
||||
results = await super()._resolve_host(host, port, traces=traces)
|
||||
_assert_addresses_allowed([entry['host'] for entry in results])
|
||||
return results
|
||||
|
||||
|
||||
def get_ssrf_safe_session() -> aiohttp.ClientSession:
|
||||
"""A one-off aiohttp session that re-validates the connect-time IP via _SSRFSafeResolver,
|
||||
def get_ssrf_safe_session(trust_env: bool = True, store_cookies: bool = True) -> aiohttp.ClientSession:
|
||||
"""A one-off aiohttp session that re-validates every connection via _SSRFSafeConnector,
|
||||
defeating DNS rebinding. Use for validate_url-gated fetches of user-supplied URLs that must
|
||||
not use the shared (rebinding-vulnerable) pool. Use as a context manager so it is closed:
|
||||
``async with get_ssrf_safe_session() as session: ...``.
|
||||
|
||||
trust_env also enables environment proxies, and proxied traffic bypasses the connect-time
|
||||
IP check, because the proxy resolves the hostname instead.
|
||||
"""
|
||||
return aiohttp.ClientSession(
|
||||
connector=aiohttp.TCPConnector(resolver=_SSRFSafeResolver()),
|
||||
connector=_SSRFSafeConnector(),
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
trust_env=True,
|
||||
trust_env=trust_env,
|
||||
cookie_jar=None if store_cookies else aiohttp.DummyCookieJar(),
|
||||
)
|
||||
|
||||
|
||||
def get_ssrf_safe_requests_session(trust_env: bool = True, store_cookies: bool = True) -> requests.Session:
|
||||
"""The requests counterpart of get_ssrf_safe_session, with the same proxy caveat."""
|
||||
session = requests.Session()
|
||||
session.trust_env = trust_env
|
||||
if not store_cookies:
|
||||
session.cookies.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[]))
|
||||
session.mount('http://', _SSRFSafeAdapter())
|
||||
session.mount('https://', _SSRFSafeAdapter())
|
||||
return session
|
||||
|
||||
|
||||
# accept-encoding goes because the client must advertise only codecs it can decode, the rest
|
||||
# because the client derives them from the URL and body it is actually given. content-encoding
|
||||
# stays: the browser's body is forwarded byte for byte, so its own labelling still applies.
|
||||
_DROPPED_REQUEST_HEADERS = {'accept-encoding', 'connection', 'content-length', 'host', 'transfer-encoding'}
|
||||
|
||||
# The clients hand us a decoded body, so the sender's framing no longer describes it.
|
||||
_DROPPED_RESPONSE_HEADERS = {'connection', 'content-encoding', 'content-length', 'transfer-encoding'}
|
||||
|
||||
|
||||
def _forwardable_request_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
return {name: value for name, value in headers.items() if name.lower() not in _DROPPED_REQUEST_HEADERS}
|
||||
|
||||
|
||||
def _fulfillable_response_headers(header_pairs: Iterable[Tuple[str, str]]) -> Dict[str, str]:
|
||||
"""Collapse repeated headers the way route.fulfill expects: set-cookie by newline, rest by comma.
|
||||
|
||||
Takes pairs rather than a mapping because reading either client's headers as a mapping loses
|
||||
duplicate Set-Cookie values, leaving one malformed cookie or one of the two.
|
||||
"""
|
||||
collected: Dict[str, List[str]] = {}
|
||||
for name, value in header_pairs:
|
||||
name = name.lower() # grouping by the sender's case would split a repeated header
|
||||
if name not in _DROPPED_RESPONSE_HEADERS:
|
||||
collected.setdefault(name, []).append(value)
|
||||
return {name: ('\n' if name == 'set-cookie' else ', ').join(values) for name, values in collected.items()}
|
||||
|
||||
|
||||
def extract_metadata(soup, url):
|
||||
metadata = {'source': url}
|
||||
if title := soup.find('title'):
|
||||
|
|
@ -404,7 +472,6 @@ class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
def __init__(
|
||||
self,
|
||||
web_paths: Union[str, List[str]],
|
||||
api_base_url: str,
|
||||
api_key: str,
|
||||
extract_depth: Literal['basic', 'advanced'] = 'basic',
|
||||
continue_on_failure: bool = True,
|
||||
|
|
@ -438,7 +505,6 @@ class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
|
||||
# Store parameters for creating TavilyLoader instances
|
||||
self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths]
|
||||
self.api_base_url = api_base_url
|
||||
self.api_key = api_key
|
||||
self.extract_depth = extract_depth
|
||||
self.continue_on_failure = continue_on_failure
|
||||
|
|
@ -519,6 +585,7 @@ class SafeMicrosoftWebIQLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
self,
|
||||
web_paths: Union[str, List[str]],
|
||||
api_key: str,
|
||||
api_base_url: str = MICROSOFT_WEB_IQ_API_BASE_URL,
|
||||
language: str = 'en',
|
||||
verify_ssl: bool = True,
|
||||
trust_env: bool = False,
|
||||
|
|
@ -528,6 +595,7 @@ class SafeMicrosoftWebIQLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
):
|
||||
self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths]
|
||||
self.api_key = api_key
|
||||
self.api_base_url = api_base_url
|
||||
self.language = language
|
||||
self.verify_ssl = verify_ssl
|
||||
self.trust_env = trust_env
|
||||
|
|
@ -585,7 +653,9 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
requests_per_second (Optional[float]): Number of requests per second to limit to.
|
||||
continue_on_failure (bool): If True, continue loading other URLs on failure.
|
||||
headless (bool): If True, the browser will run in headless mode.
|
||||
proxy (dict): Proxy override settings for the Playwright session.
|
||||
proxy (dict): Proxy override settings for the Playwright session. Page requests are
|
||||
issued outside the browser, so they follow the environment proxy via trust_env
|
||||
rather than this setting.
|
||||
playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection.
|
||||
playwright_timeout (Optional[int]): Maximum operation time in milliseconds.
|
||||
"""
|
||||
|
|
@ -630,14 +700,49 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
self.trust_env = trust_env
|
||||
self.playwright_timeout = playwright_timeout
|
||||
|
||||
def _intercept_navigation_sync(self, route, request=None):
|
||||
req = request or route.request
|
||||
def _request_timeout(self) -> float:
|
||||
# per-hop budget, since page.goto's timeout cannot reach into our own fetch and 0 disables
|
||||
# it. aiohttp treats it as a total where requests only caps each read, so sync runs looser.
|
||||
return (self.playwright_timeout or 30000) / 1000
|
||||
|
||||
def _requests_verify(self) -> Union[bool, str]:
|
||||
"""requests takes a CA path where aiohttp takes the parsed SSLContext.
|
||||
|
||||
A bundle named directly in AIOHTTP_CLIENT_SESSION_SSL reaches us already parsed and
|
||||
cannot be expressed here, so that form falls back to the global bundle or certifi.
|
||||
"""
|
||||
if not self.verify_ssl or AIOHTTP_CLIENT_SESSION_SSL is False:
|
||||
return False
|
||||
if AIOHTTP_CLIENT_SESSION_SSL is True:
|
||||
return True # no usable global CA bundle, so both clients land on certifi
|
||||
return AIOHTTP_CLIENT_SSL_CERT_FILE or True
|
||||
|
||||
def _intercept_navigation_sync(self, route, session):
|
||||
req = route.request
|
||||
|
||||
hop_cookies: List[Tuple[str, str]] = []
|
||||
|
||||
try:
|
||||
validate_url(req.url)
|
||||
resp = route.fetch(max_redirects=0)
|
||||
headers = _forwardable_request_headers(req.all_headers())
|
||||
post_data = req.post_data_buffer
|
||||
verify, timeout = self._requests_verify(), self._request_timeout()
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
# The browser would resolve the hostname again, after the check; fetch it ourselves.
|
||||
def fetch(url):
|
||||
validate_url(url)
|
||||
return session.request(
|
||||
req.method,
|
||||
url,
|
||||
headers=headers,
|
||||
data=post_data,
|
||||
allow_redirects=False,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
resp = fetch(req.url)
|
||||
|
||||
if 300 <= resp.status_code < 400:
|
||||
for _ in range(20):
|
||||
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
route.abort()
|
||||
|
|
@ -647,26 +752,50 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
if not location:
|
||||
break
|
||||
|
||||
url = urllib.parse.urljoin(resp.url, location)
|
||||
validate_url(url)
|
||||
resp = route.fetch(url=url, max_redirects=0)
|
||||
if not 300 <= resp.status < 400:
|
||||
# only the last hop is fulfilled, so carry each hop's cookies to the browser
|
||||
hop_cookies += [('set-cookie', v) for v in resp.raw.headers.getlist('set-cookie')]
|
||||
resp = fetch(urllib.parse.urljoin(resp.url, location))
|
||||
if not 300 <= resp.status_code < 400:
|
||||
break
|
||||
else:
|
||||
route.abort()
|
||||
return
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.debug('Playwright loader could not fetch %s: %s', req.url, e)
|
||||
route.abort()
|
||||
return
|
||||
|
||||
route.fulfill(response=resp)
|
||||
route.fulfill(
|
||||
status=resp.status_code,
|
||||
headers=_fulfillable_response_headers(hop_cookies + list(resp.raw.headers.items())),
|
||||
body=resp.content,
|
||||
)
|
||||
|
||||
async def _intercept_navigation(self, route, request=None):
|
||||
req = request or route.request
|
||||
async def _intercept_navigation(self, route, session):
|
||||
req = route.request
|
||||
|
||||
hop_cookies: List[Tuple[str, str]] = []
|
||||
|
||||
try:
|
||||
await run_in_threadpool(validate_url, req.url)
|
||||
resp = await route.fetch(max_redirects=0)
|
||||
headers = _forwardable_request_headers(await req.all_headers())
|
||||
post_data = req.post_data_buffer
|
||||
|
||||
# The browser would resolve the hostname again, after the check; fetch it ourselves.
|
||||
async def fetch(url):
|
||||
await run_in_threadpool(validate_url, url)
|
||||
response = await session.request(
|
||||
req.method,
|
||||
url,
|
||||
headers=headers,
|
||||
data=post_data,
|
||||
allow_redirects=False,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL if self.verify_ssl else False,
|
||||
timeout=aiohttp.ClientTimeout(total=self._request_timeout()),
|
||||
)
|
||||
# aiohttp only returns the connection to the pool once the body is buffered
|
||||
return response, await response.read()
|
||||
|
||||
resp, body = await fetch(req.url)
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
for _ in range(20):
|
||||
|
|
@ -678,19 +807,24 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
if not location:
|
||||
break
|
||||
|
||||
url = urllib.parse.urljoin(resp.url, location)
|
||||
await run_in_threadpool(validate_url, url)
|
||||
resp = await route.fetch(url=url, max_redirects=0)
|
||||
# only the last hop is fulfilled, so carry each hop's cookies to the browser
|
||||
hop_cookies += [('set-cookie', v) for v in resp.headers.getall('Set-Cookie', [])]
|
||||
resp, body = await fetch(urllib.parse.urljoin(str(resp.url), location))
|
||||
if not 300 <= resp.status < 400:
|
||||
break
|
||||
else:
|
||||
await route.abort()
|
||||
return
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.debug('Playwright loader could not fetch %s: %s', req.url, e)
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
await route.fulfill(response=resp)
|
||||
await route.fulfill(
|
||||
status=resp.status,
|
||||
headers=_fulfillable_response_headers(hop_cookies + list(resp.headers.items())),
|
||||
body=body,
|
||||
)
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
"""Safely load URLs synchronously with support for remote browser."""
|
||||
|
|
@ -707,8 +841,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
for url in self.urls:
|
||||
try:
|
||||
self._safe_process_url_sync(url)
|
||||
with browser.new_page(service_workers='block') as page:
|
||||
page.route('**/*', self._intercept_navigation_sync)
|
||||
# opened before the page so it outlives any route still in flight at teardown
|
||||
with (
|
||||
get_ssrf_safe_requests_session(self.trust_env, store_cookies=False) as session,
|
||||
browser.new_page(service_workers='block') as page,
|
||||
):
|
||||
page.route('**/*', lambda route: self._intercept_navigation_sync(route, session))
|
||||
page.route_web_socket('**/*', lambda ws_route: ws_route.close())
|
||||
response = page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
|
|
@ -738,8 +876,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
for url in self.urls:
|
||||
try:
|
||||
await self._safe_process_url(url)
|
||||
async with await browser.new_page(service_workers='block') as page:
|
||||
await page.route('**/*', self._intercept_navigation)
|
||||
# opened before the page so it outlives any route still in flight at teardown
|
||||
async with (
|
||||
get_ssrf_safe_session(self.trust_env, store_cookies=False) as session,
|
||||
await browser.new_page(service_workers='block') as page,
|
||||
):
|
||||
await page.route('**/*', lambda route: self._intercept_navigation(route, session))
|
||||
await page.route_web_socket('**/*', lambda ws_route: ws_route.close())
|
||||
response = await page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
|
|
@ -793,7 +935,7 @@ class SafeWebBaseLoader(WebBaseLoader):
|
|||
self.session.mount('https://', _SSRFSafeAdapter())
|
||||
|
||||
async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str:
|
||||
connector = aiohttp.TCPConnector(resolver=_SSRFSafeResolver())
|
||||
connector = _SSRFSafeConnector()
|
||||
async with aiohttp.ClientSession(trust_env=self.trust_env, connector=connector) as session:
|
||||
for i in range(retries):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -53,12 +53,15 @@ def search_yacy(
|
|||
# Strip all query parameters from the URL
|
||||
query_url = query_url.rstrip('/') + '/yacysearch.json'
|
||||
|
||||
log.debug(f'searching {query_url}')
|
||||
log.debug('searching %s', query_url)
|
||||
|
||||
response = requests.get(
|
||||
query_url,
|
||||
auth=yacy_auth,
|
||||
headers={
|
||||
# LICENSE covers this Open WebUI user-agent identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
|
@ -12,6 +11,7 @@ from fastapi import Request
|
|||
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -41,6 +41,9 @@ def search_yandex(
|
|||
) -> List[SearchResult]:
|
||||
try:
|
||||
headers = {
|
||||
# LICENSE covers this Open WebUI user-agent identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot',
|
||||
'Authorization': f'Api-Key {yandex_search_api_key}',
|
||||
}
|
||||
|
|
@ -52,7 +55,7 @@ def search_yandex(
|
|||
if chat_id:
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
|
||||
|
||||
payload = {} if yandex_search_config == '' else json.loads(yandex_search_config)
|
||||
payload = {} if yandex_search_config == '' else JSONCodec.loads(yandex_search_config)
|
||||
|
||||
if type(payload.get('query', None)) != dict:
|
||||
payload['query'] = {}
|
||||
|
|
@ -109,7 +112,7 @@ def search_yandex(
|
|||
for result in results[:count]
|
||||
]
|
||||
|
||||
log.info(f'Yandex search results: {results}')
|
||||
log.info('Yandex search results: %s', results)
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import base64
|
|||
import hashlib
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
|
|
@ -27,13 +26,6 @@ from fastapi import (
|
|||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
# pydub needs stdlib audioop (gone in 3.13); keep requires-python capped < 3.13
|
||||
from pydub import AudioSegment
|
||||
from pydub.silence import split_on_silence
|
||||
from pydub.utils import mediainfo
|
||||
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
ELEVENLABS_API_BASE_URL,
|
||||
|
|
@ -60,8 +52,15 @@ from open_webui.models.config import Config
|
|||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.misc import strict_match_mime_type
|
||||
from open_webui.utils.session_pool import get_session
|
||||
from pydantic import BaseModel
|
||||
|
||||
# pydub needs stdlib audioop (gone in 3.13); keep requires-python capped < 3.13
|
||||
from pydub import AudioSegment
|
||||
from pydub.silence import split_on_silence
|
||||
from pydub.utils import mediainfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
@ -158,7 +157,7 @@ def convert_audio_to_mp3(file_path):
|
|||
output_path = os.path.splitext(file_path)[0] + '.mp3'
|
||||
audio = AudioSegment.from_file(file_path)
|
||||
audio.export(output_path, format='mp3')
|
||||
log.info(f'Converted {file_path} to {output_path}')
|
||||
log.info('Converted %s to %s', file_path, output_path)
|
||||
return output_path
|
||||
except Exception as e:
|
||||
log.error(f'Error converting audio file: {e}')
|
||||
|
|
@ -209,7 +208,7 @@ def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_p
|
|||
audio_segment = AudioSegment.from_file(io.BytesIO(audio_data))
|
||||
|
||||
audio_segment.export(str(output_path), format='mp3')
|
||||
log.info(f'Transcoded {mime_type} audio to MP3: {output_path}')
|
||||
log.info('Transcoded %s audio to MP3: %s', mime_type, output_path)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -330,6 +329,9 @@ def load_speech_pipeline(request):
|
|||
async def _raise_tts_error(exc: Exception, r=None) -> None:
|
||||
"""Raise a standardised HTTPException from a TTS provider failure."""
|
||||
code = r.status if r is not None else 500
|
||||
# LICENSE covers this Open WebUI error identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
detail = 'Open WebUI: Server Connection Error'
|
||||
if r is not None:
|
||||
try:
|
||||
|
|
@ -354,7 +356,7 @@ async def _write_tts_cache(
|
|||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(audio)
|
||||
async with aiofiles.open(body_path, 'w') as f:
|
||||
await f.write(json.dumps(payload))
|
||||
await f.write(JSONCodec.dumps(payload))
|
||||
|
||||
|
||||
async def _tts_openai(request, payload, file_path, file_body_path, user):
|
||||
|
|
@ -392,7 +394,7 @@ async def _tts_openai(request, payload, file_path, file_body_path, user):
|
|||
await f.write(audio_data)
|
||||
|
||||
async with aiofiles.open(file_body_path, 'w') as f:
|
||||
await f.write(json.dumps(payload))
|
||||
await f.write(JSONCodec.dumps(payload))
|
||||
|
||||
return FileResponse(file_path)
|
||||
except Exception as exc:
|
||||
|
|
@ -484,7 +486,7 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user):
|
|||
try:
|
||||
idx = embeddings['filename'].index(model_name)
|
||||
except (ValueError, KeyError):
|
||||
log.debug(f'Speaker embedding not found for {model_name}, using default index {idx}')
|
||||
log.debug('Speaker embedding not found for %s, using default index %s', model_name, idx)
|
||||
|
||||
def _run_pipeline():
|
||||
speaker_embedding = torch.tensor(embeddings[idx]['xvector']).unsqueeze(0)
|
||||
|
|
@ -500,7 +502,7 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user):
|
|||
|
||||
# Audio file already written by sf.write; just persist the request metadata.
|
||||
async with aiofiles.open(file_body_path, 'w') as f:
|
||||
await f.write(json.dumps(payload))
|
||||
await f.write(JSONCodec.dumps(payload))
|
||||
return FileResponse(file_path)
|
||||
|
||||
|
||||
|
|
@ -588,7 +590,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
return FileResponse(file_path)
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
payload = JSONCodec.loads(body)
|
||||
except Exception as exc:
|
||||
log.exception(exc)
|
||||
raise HTTPException(status_code=400, detail='Invalid JSON payload')
|
||||
|
|
@ -629,14 +631,14 @@ async def _transcribe_whisper(request, file_path, languages, file_dir, id):
|
|||
language=languages[0],
|
||||
multilingual=WHISPER_MULTILINGUAL,
|
||||
)
|
||||
log.info("Detected language '%s' with probability %f" % (info.language, info.language_probability))
|
||||
log.info("Detected language '%s' with probability %f", info.language, info.language_probability)
|
||||
return ''.join([segment.text for segment in list(segments)])
|
||||
|
||||
transcript = await asyncio.to_thread(_run)
|
||||
data = {'text': transcript.strip()}
|
||||
|
||||
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||
await f.write(json.dumps(data))
|
||||
await f.write(JSONCodec.dumps(data))
|
||||
|
||||
log.debug(data)
|
||||
return data
|
||||
|
|
@ -699,7 +701,7 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
|
|||
data = await r.json()
|
||||
|
||||
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||
await f.write(json.dumps(data))
|
||||
await f.write(JSONCodec.dumps(data))
|
||||
return data
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
|
@ -711,6 +713,9 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
|
|||
detail = f'External: {res["error"].get("message", "")}'
|
||||
except Exception:
|
||||
detail = f'External: {e}'
|
||||
# LICENSE covers this Open WebUI error identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
raise Exception(detail if detail else 'Open WebUI: Server Connection Error')
|
||||
|
||||
|
||||
|
|
@ -756,11 +761,14 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
|
|||
|
||||
data = {'text': transcript}
|
||||
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||
await f.write(json.dumps(data))
|
||||
await f.write(JSONCodec.dumps(data))
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
# LICENSE covers this Open WebUI error identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
detail = 'Open WebUI: Server Connection Error'
|
||||
if r is not None:
|
||||
try:
|
||||
|
|
@ -819,7 +827,7 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
|||
raise HTTPException(status_code=400, detail='Azure API key and region are required for Azure STT')
|
||||
|
||||
# Build the transcription definition payload
|
||||
definition = json.dumps(
|
||||
definition = JSONCodec.dumps(
|
||||
{'locales': locale_str.split(','), 'diarization': {'maxSpeakers': max_speakers, 'enabled': True}}
|
||||
if locale_str
|
||||
else {}
|
||||
|
|
@ -859,7 +867,7 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
|||
data = {'text': transcript}
|
||||
|
||||
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||
await f.write(json.dumps(data))
|
||||
await f.write(JSONCodec.dumps(data))
|
||||
|
||||
log.debug(data)
|
||||
return data
|
||||
|
|
@ -890,6 +898,9 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
|||
detail = f'External: {res["error"].get("message", "")}'
|
||||
except Exception:
|
||||
detail = f'External: {e}'
|
||||
# LICENSE covers this Open WebUI error identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
raise HTTPException(
|
||||
status_code=e.status if e.status else 500,
|
||||
detail=detail if detail else 'Open WebUI: Server Connection Error',
|
||||
|
|
@ -941,7 +952,9 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
|||
try:
|
||||
model = await Config.get('audio.stt.model') or 'voxtral-mini-latest'
|
||||
log.info(
|
||||
f'Mistral STT - model: {model}, method: {"chat_completions" if use_chat_completions else "transcriptions"}'
|
||||
'Mistral STT - model: %s, method: %s',
|
||||
model,
|
||||
'chat_completions' if use_chat_completions else 'transcriptions',
|
||||
)
|
||||
|
||||
session = await get_session()
|
||||
|
|
@ -1034,7 +1047,7 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
|||
data = {'text': transcript}
|
||||
|
||||
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||
await f.write(json.dumps(data))
|
||||
await f.write(JSONCodec.dumps(data))
|
||||
|
||||
log.debug(data)
|
||||
return data
|
||||
|
|
@ -1054,6 +1067,9 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
|||
detail = f'External: {await r.text()}'
|
||||
except Exception:
|
||||
detail = f'External: {e}'
|
||||
# LICENSE covers this Open WebUI error identifier.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
raise HTTPException(
|
||||
status_code=e.status if e.status else 500,
|
||||
detail=detail if detail else 'Open WebUI: Server Connection Error',
|
||||
|
|
@ -1061,7 +1077,7 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
|||
|
||||
|
||||
async def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None):
|
||||
log.info(f'transcribe: {file_path} {metadata}')
|
||||
log.info('transcribe: %s %s', file_path, metadata)
|
||||
|
||||
if BYPASS_PYDUB_PREPROCESSING:
|
||||
log.info('Bypassing pydub preprocessing (BYPASS_PYDUB_PREPROCESSING=true)')
|
||||
|
|
@ -1188,7 +1204,7 @@ async def transcription(
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
log.info(f'file.content_type: {file.content_type}')
|
||||
log.info('file.content_type: %s', file.content_type)
|
||||
stt_supported_content_types = await Config.get('audio.stt.supported_content_types', [])
|
||||
|
||||
if not strict_match_mime_type(stt_supported_content_types, file.content_type):
|
||||
|
|
@ -1289,7 +1305,7 @@ async def get_available_models(request: Request) -> list[dict]:
|
|||
data = await resp.json()
|
||||
available_models = data.get('models', [])
|
||||
except Exception as e:
|
||||
log.debug(f'/audio/models not available, trying /models fallback: {e}')
|
||||
log.debug('/audio/models not available, trying /models fallback: %s', e)
|
||||
try:
|
||||
async with session.get(
|
||||
f'{base_url}/models',
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ from open_webui.utils.auth import (
|
|||
get_password_hash,
|
||||
get_verified_user,
|
||||
invalidate_token,
|
||||
revoke_user_tokens,
|
||||
validate_password,
|
||||
verify_password,
|
||||
)
|
||||
|
|
@ -111,6 +112,7 @@ ADMIN_CONFIG_KEYS = {
|
|||
'API_KEYS_ALLOWED_ENDPOINTS': 'auth.api_key.allowed_endpoints',
|
||||
'DEFAULT_USER_ROLE': 'ui.default_user_role',
|
||||
'DEFAULT_GROUP_ID': 'ui.default_group_id',
|
||||
'DEFAULT_INTERFACE_SETTINGS': 'ui.default_interface_settings',
|
||||
'JWT_EXPIRES_IN': 'auth.jwt_expiry',
|
||||
'ENABLE_COMMUNITY_SHARING': 'ui.enable_community_sharing',
|
||||
'ENABLE_MESSAGE_RATING': 'ui.enable_message_rating',
|
||||
|
|
@ -405,6 +407,7 @@ async def update_password(
|
|||
hashed = await get_password_hash(form_data.new_password)
|
||||
success = await Auths.update_user_password_by_id(user.id, hashed, db=db)
|
||||
if success:
|
||||
await revoke_user_tokens(request, user.id)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.AUTH_PASSWORD_CHANGED,
|
||||
|
|
@ -548,8 +551,8 @@ async def ldap_auth(
|
|||
]
|
||||
if ENABLE_LDAP_GROUP_MANAGEMENT:
|
||||
search_attributes.append(f'{LDAP_ATTRIBUTE_FOR_GROUPS}')
|
||||
log.info(f'LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes')
|
||||
log.info(f'LDAP search attributes: {search_attributes}')
|
||||
log.info('LDAP Group Management enabled. Adding %s to search attributes', LDAP_ATTRIBUTE_FOR_GROUPS)
|
||||
log.info('LDAP search attributes: %s', search_attributes)
|
||||
|
||||
search_success = await asyncio.to_thread(
|
||||
connection_app.search,
|
||||
|
|
@ -586,30 +589,30 @@ async def ldap_auth(
|
|||
user_groups = []
|
||||
if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry:
|
||||
group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS]
|
||||
log.info(f'LDAP raw group DNs for user {username_list}: {group_dns}')
|
||||
log.info('LDAP raw group DNs for user %s: %s', username_list, group_dns)
|
||||
|
||||
if group_dns:
|
||||
log.info(f'LDAP group_dns original: {group_dns}')
|
||||
log.info(f'LDAP group_dns type: {type(group_dns)}')
|
||||
log.info(f'LDAP group_dns length: {len(group_dns)}')
|
||||
log.info('LDAP group_dns original: %s', group_dns)
|
||||
log.info('LDAP group_dns type: %s', type(group_dns))
|
||||
log.info('LDAP group_dns length: %s', len(group_dns))
|
||||
|
||||
if hasattr(group_dns, 'value'):
|
||||
group_dns = group_dns.value
|
||||
log.info(f'Extracted .value property: {group_dns}')
|
||||
log.info('Extracted .value property: %s', group_dns)
|
||||
elif hasattr(group_dns, '__iter__') and not isinstance(group_dns, (str, bytes)):
|
||||
group_dns = list(group_dns)
|
||||
log.info(f'Converted to list: {group_dns}')
|
||||
log.info('Converted to list: %s', group_dns)
|
||||
|
||||
if isinstance(group_dns, list):
|
||||
group_dns = [str(item) for item in group_dns]
|
||||
else:
|
||||
group_dns = [str(group_dns)]
|
||||
|
||||
log.info(f'LDAP group_dns after processing - type: {type(group_dns)}, length: {len(group_dns)}')
|
||||
log.info('LDAP group_dns after processing - type: %s, length: %s', type(group_dns), len(group_dns))
|
||||
|
||||
for group_idx, group_dn in enumerate(group_dns):
|
||||
group_dn = str(group_dn)
|
||||
log.info(f'Processing group DN #{group_idx + 1}: {group_dn}')
|
||||
log.info('Processing group DN #%s: %s', group_idx + 1, group_dn)
|
||||
|
||||
try:
|
||||
group_cn = extract_group_cn_from_dn(group_dn)
|
||||
|
|
@ -621,9 +624,9 @@ async def ldap_auth(
|
|||
except Exception as e:
|
||||
log.warning(f'Failed to extract group name from DN {group_dn}: {e}')
|
||||
|
||||
log.info(f'LDAP groups for user {username_list}: {user_groups} (total: {len(user_groups)})')
|
||||
log.info('LDAP groups for user %s: %s (total: %s)', username_list, user_groups, len(user_groups))
|
||||
else:
|
||||
log.info(f'No groups found for user {username_list}')
|
||||
log.info('No groups found for user %s', username_list)
|
||||
elif ENABLE_LDAP_GROUP_MANAGEMENT:
|
||||
log.warning(
|
||||
f'LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry'
|
||||
|
|
@ -691,7 +694,7 @@ async def ldap_auth(
|
|||
if ENABLE_LDAP_GROUP_CREATION:
|
||||
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
|
||||
await Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
|
||||
log.info(f'Successfully synced groups for user {user.id}: {user_groups}')
|
||||
log.info('Successfully synced groups for user %s: %s', user.id, user_groups)
|
||||
except Exception as e:
|
||||
log.error(f'Failed to sync groups for user {user.id}: {e}')
|
||||
|
||||
|
|
@ -767,7 +770,17 @@ async def signin(
|
|||
trusted_role = request.headers.get(WEBUI_AUTH_TRUSTED_ROLE_HEADER, '').lower().strip()
|
||||
if trusted_role in {'admin', 'user', 'pending'}:
|
||||
if user.role != trusted_role:
|
||||
await Users.update_user_role_by_id(user.id, trusted_role, db=db)
|
||||
updated_user = await Users.update_user_role_by_id(user.id, trusted_role, db=db)
|
||||
if updated_user:
|
||||
user = updated_user
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.USER_ROLE_UPDATED,
|
||||
actor=updated_user,
|
||||
subject_id=updated_user.id,
|
||||
source='trusted_header',
|
||||
data={'role': updated_user.role},
|
||||
)
|
||||
elif trusted_role:
|
||||
log.warning(f'Ignoring invalid trusted role header value: {trusted_role}')
|
||||
|
||||
|
|
@ -950,6 +963,9 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
|
|||
if token is None:
|
||||
token = request.cookies.get('token')
|
||||
|
||||
oauth_session_id = request.cookies.get('oauth_session_id')
|
||||
session = await OAuthSessions.get_session_by_id(oauth_session_id, db=db) if oauth_session_id else None
|
||||
|
||||
if token:
|
||||
actor = None
|
||||
data = decode_token(token)
|
||||
|
|
@ -962,18 +978,21 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
|
|||
actor=actor,
|
||||
subject_id=actor.id if actor else None,
|
||||
subject_type='user' if actor else None,
|
||||
**({'source': 'oauth', 'data': {'auth_method': 'oauth', 'provider': session.provider}} if session else {}),
|
||||
)
|
||||
|
||||
response.delete_cookie('token')
|
||||
try:
|
||||
request.session.clear()
|
||||
except Exception:
|
||||
pass
|
||||
response.delete_cookie('owui-session')
|
||||
response.delete_cookie('oui-session')
|
||||
response.delete_cookie('oauth_id_token')
|
||||
|
||||
oauth_session_id = request.cookies.get('oauth_session_id')
|
||||
if oauth_session_id:
|
||||
response.delete_cookie('oauth_session_id')
|
||||
|
||||
session = await OAuthSessions.get_session_by_id(oauth_session_id, db=db)
|
||||
|
||||
# If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect
|
||||
# there directly instead of attempting OIDC discovery.
|
||||
openid_end_session_endpoint = await Config.get('oauth.end_session_endpoint')
|
||||
|
|
@ -1156,7 +1175,7 @@ async def get_admin_details(
|
|||
admin_email = await Config.get('auth.admin.email')
|
||||
admin_name = None
|
||||
|
||||
log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}')
|
||||
log.info('Admin details - Email: %s, Name: %s', admin_email, admin_name)
|
||||
|
||||
if admin_email:
|
||||
admin = await Users.get_user_by_email(admin_email, db=db)
|
||||
|
|
@ -1196,6 +1215,7 @@ class AdminConfig(BaseModel):
|
|||
API_KEYS_ALLOWED_ENDPOINTS: str
|
||||
DEFAULT_USER_ROLE: str
|
||||
DEFAULT_GROUP_ID: str
|
||||
DEFAULT_INTERFACE_SETTINGS: dict | None = None
|
||||
JWT_EXPIRES_IN: str
|
||||
ENABLE_COMMUNITY_SHARING: bool
|
||||
ENABLE_MESSAGE_RATING: bool
|
||||
|
|
@ -1220,6 +1240,7 @@ class AdminConfig(BaseModel):
|
|||
@router.post('/admin/config')
|
||||
async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)):
|
||||
updates = config_updates(form_data.model_dump(), ADMIN_CONFIG_KEYS)
|
||||
updates['ui.default_interface_settings'] = form_data.DEFAULT_INTERFACE_SETTINGS or {}
|
||||
updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
|
||||
updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''
|
||||
updates['automations.min_interval'] = (
|
||||
|
|
@ -1425,11 +1446,13 @@ def _parse_oauth_update_value(field: str, value):
|
|||
|
||||
async def get_oauth_config_values() -> dict:
|
||||
values = await Config.get_many(*OAUTH_CONFIG_KEYS.values())
|
||||
return {
|
||||
form_values = {
|
||||
field: _format_oauth_form_value(field, values[storage_key])
|
||||
for field, storage_key in OAUTH_CONFIG_KEYS.items()
|
||||
if storage_key in values
|
||||
}
|
||||
form_values['ENABLE_OAUTH_PERSISTENT_CONFIG'] = Config.OAUTH_PERSISTENT_ENABLED
|
||||
return form_values
|
||||
|
||||
|
||||
def oauth_config_updates(data: dict) -> dict:
|
||||
|
|
@ -1440,12 +1463,16 @@ def oauth_config_updates(data: dict) -> dict:
|
|||
}
|
||||
|
||||
|
||||
@router.get('/admin/config/oauth', response_model=OAuthConfigForm)
|
||||
class OAuthConfigResponse(OAuthConfigForm):
|
||||
ENABLE_OAUTH_PERSISTENT_CONFIG: bool
|
||||
|
||||
|
||||
@router.get('/admin/config/oauth', response_model=OAuthConfigResponse)
|
||||
async def get_oauth_config(request: Request, user=Depends(get_admin_user)):
|
||||
return await get_oauth_config_values()
|
||||
|
||||
|
||||
@router.post('/admin/config/oauth', response_model=OAuthConfigForm)
|
||||
@router.post('/admin/config/oauth', response_model=OAuthConfigResponse)
|
||||
async def update_oauth_config(request: Request, form_data: OAuthConfigForm, user=Depends(get_admin_user)):
|
||||
await Config.upsert(oauth_config_updates(form_data.model_dump(exclude_none=True)))
|
||||
return await get_oauth_config_values()
|
||||
|
|
@ -1644,6 +1671,7 @@ async def token_exchange(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token missing required 'sub' claim",
|
||||
)
|
||||
sub = str(sub)
|
||||
|
||||
email = user_data.get(email_claim, '')
|
||||
if not email:
|
||||
|
|
@ -1673,7 +1701,13 @@ async def token_exchange(
|
|||
user = await Users.get_user_by_email(email, db=db)
|
||||
if user:
|
||||
# Link the OAuth sub to this user
|
||||
await Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
|
||||
user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user
|
||||
|
||||
if user:
|
||||
provider_oauth = (user.oauth or {}).get(provider) if isinstance(user.oauth, dict) else None
|
||||
# Lazy repair for legacy rows that stored numeric provider ids as JSON numbers.
|
||||
if isinstance(provider_oauth, dict) and provider_oauth.get('sub') != sub:
|
||||
user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
|
|
@ -1681,4 +1715,20 @@ async def token_exchange(
|
|||
detail='User not found. Please sign in via the web interface first.',
|
||||
)
|
||||
|
||||
user = await oauth_manager.update_user_role_from_oauth(
|
||||
request=request,
|
||||
user=user,
|
||||
user_data=user_data,
|
||||
provider=provider,
|
||||
db=db,
|
||||
)
|
||||
if await Config.get('oauth.enable_group_mapping'):
|
||||
await oauth_manager.update_user_groups(
|
||||
request=request,
|
||||
user=user,
|
||||
user_data=user_data,
|
||||
default_permissions=await Config.get('user.permissions'),
|
||||
db=db,
|
||||
)
|
||||
|
||||
return await create_session_response(request, user, db, source='oauth')
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from open_webui.models.automations import (
|
|||
AutomationRuns,
|
||||
Automations,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_write_access_grant
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
|
@ -104,6 +106,44 @@ async def check_automation_folder_access(folder_id: Optional[str], user, db: Asy
|
|||
)
|
||||
|
||||
|
||||
async def check_automation_channel_access(form_data: AutomationForm, user, db: AsyncSession):
|
||||
target = form_data.data.target
|
||||
if not target or target.type != 'channel':
|
||||
return
|
||||
|
||||
if not target.channel_id or not await Config.get('channels.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
channel = await Channels.get_channel_by_id(target.channel_id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if user.role == 'admin':
|
||||
return
|
||||
if not await has_permission(user.id, 'features.channels', await Config.get('user.permissions')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
if channel.type in ['group', 'dm']:
|
||||
allowed = await Channels.is_user_channel_member(channel.id, user.id, db=db)
|
||||
else:
|
||||
allowed = has_public_write_access_grant(channel.access_grants) or await AccessGrants.has_access(
|
||||
user_id=user.id, resource_type='channel', resource_id=channel.id, permission='write', db=db
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
|
||||
"""Full enrichment for single-item views (includes next_runs computation)."""
|
||||
last_run = await AutomationRuns.get_latest(automation.id, db=db)
|
||||
|
|
@ -174,6 +214,7 @@ async def create_new_automation(
|
|||
):
|
||||
await check_automations_permission(request, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
await check_automation_channel_access(form_data, user, db)
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
except ValueError as e:
|
||||
|
|
@ -232,6 +273,7 @@ async def update_automation_by_id(
|
|||
automation = await Automations.get_by_id(id, db=db)
|
||||
check_automation_access(automation, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
await check_automation_channel_access(form_data, user, db)
|
||||
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -115,6 +114,23 @@ def get_channel_permitted_group_and_user_ids(
|
|||
}
|
||||
|
||||
|
||||
async def get_channel_member_user_ids(
|
||||
channel: ChannelModel,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[list[str]]:
|
||||
permitted_ids = get_channel_permitted_group_and_user_ids(channel, permission='read')
|
||||
if permitted_ids is None:
|
||||
return None
|
||||
|
||||
user_ids = permitted_ids.get('user_ids') or []
|
||||
group_ids = permitted_ids.get('group_ids') or []
|
||||
if group_ids:
|
||||
for member_ids in (await Groups.get_group_user_ids_by_ids(group_ids, db=db)).values():
|
||||
user_ids.extend(member_ids)
|
||||
|
||||
return list(dict.fromkeys([*user_ids, channel.user_id]))
|
||||
|
||||
|
||||
############################
|
||||
# Channels Enabled Dependency
|
||||
# The creator has set this table; let every voice that
|
||||
|
|
@ -177,7 +193,7 @@ async def get_channels(
|
|||
user_ids = None
|
||||
users = None
|
||||
if channel.type == 'dm':
|
||||
user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)]
|
||||
member_user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)]
|
||||
users = [
|
||||
UserIdNameStatusResponse(
|
||||
**{
|
||||
|
|
@ -185,8 +201,9 @@ async def get_channels(
|
|||
'is_active': Users.is_active(u),
|
||||
}
|
||||
)
|
||||
for u in await Users.get_users_by_user_ids(user_ids, db=db)
|
||||
for u in await Users.get_users_by_user_ids(member_user_ids, db=db)
|
||||
]
|
||||
user_ids = [u.id for u in users]
|
||||
|
||||
channel_list.append(
|
||||
ChannelListItemResponse(
|
||||
|
|
@ -383,7 +400,7 @@ async def get_channel_by_id(
|
|||
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)]
|
||||
member_user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)]
|
||||
|
||||
users = [
|
||||
UserIdNameStatusResponse(
|
||||
|
|
@ -392,8 +409,9 @@ async def get_channel_by_id(
|
|||
'is_active': Users.is_active(u),
|
||||
}
|
||||
)
|
||||
for u in await Users.get_users_by_user_ids(user_ids, db=db)
|
||||
for u in await Users.get_users_by_user_ids(member_user_ids, db=db)
|
||||
]
|
||||
user_ids = [u.id for u in users]
|
||||
|
||||
channel_member = await Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db)
|
||||
unread_count = await Messages.get_unread_message_count(
|
||||
|
|
@ -407,7 +425,7 @@ async def get_channel_by_id(
|
|||
'users': users,
|
||||
'is_manager': await Channels.is_user_channel_manager(channel.id, user.id, db=db),
|
||||
'write_access': True,
|
||||
'user_count': len(user_ids),
|
||||
'user_count': len(users),
|
||||
'last_read_at': channel_member.last_read_at if channel_member else None,
|
||||
'unread_count': unread_count,
|
||||
}
|
||||
|
|
@ -424,7 +442,13 @@ async def get_channel_by_id(
|
|||
db=db,
|
||||
)
|
||||
|
||||
user_count = len(await get_channel_users_with_access(channel, 'read', db=db))
|
||||
filter = {'roles': ['!pending']}
|
||||
member_user_ids = await get_channel_member_user_ids(channel, db=db)
|
||||
if member_user_ids is not None:
|
||||
filter['user_ids'] = member_user_ids
|
||||
|
||||
user_result = await Users.get_users(filter=filter, limit=0, db=db)
|
||||
user_count = user_result['total']
|
||||
|
||||
channel_member = await Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db)
|
||||
unread_count = await Messages.get_unread_message_count(
|
||||
|
|
@ -529,21 +553,22 @@ async def get_channel_members_by_id(
|
|||
|
||||
if query:
|
||||
filter['query'] = query
|
||||
if order_by:
|
||||
filter['order_by'] = order_by
|
||||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
if channel.type == 'group':
|
||||
filter['channel_id'] = channel.id
|
||||
else:
|
||||
filter['roles'] = ['!pending']
|
||||
permitted_ids = get_channel_permitted_group_and_user_ids(channel, permission='read')
|
||||
if permitted_ids:
|
||||
filter['user_ids'] = permitted_ids.get('user_ids')
|
||||
filter['group_ids'] = permitted_ids.get('group_ids')
|
||||
member_user_ids = await get_channel_member_user_ids(channel, db=db)
|
||||
if member_user_ids is not None:
|
||||
filter['user_ids'] = member_user_ids
|
||||
|
||||
result = await Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
|
||||
result = await Users.get_users(
|
||||
filter=filter,
|
||||
sort={'order_by': order_by, 'direction': direction},
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
fetched_users = result['users']
|
||||
total = result['total']
|
||||
|
|
@ -1074,16 +1099,10 @@ async def model_response_handler(request, channel, message, user, db=None):
|
|||
],
|
||||
]
|
||||
|
||||
# Resolve model config (same helpers automations use)
|
||||
from open_webui.utils.automations import (
|
||||
_resolve_model_features,
|
||||
_resolve_model_filter_ids,
|
||||
_resolve_model_tool_ids,
|
||||
)
|
||||
# Resolve model config (same path automations use)
|
||||
from open_webui.utils.automations import _resolve_model_defaults
|
||||
|
||||
tool_ids = _resolve_model_tool_ids(request.app, model_id)
|
||||
features = await _resolve_model_features(request.app, model_id)
|
||||
filter_ids = _resolve_model_filter_ids(request.app, model_id)
|
||||
tool_ids, features, filter_ids, _ = await _resolve_model_defaults(request.app, model_id)
|
||||
|
||||
# Build full form_data — same shape as frontend POST.
|
||||
# The channel: prefix routes pipeline events to the
|
||||
|
|
@ -1809,6 +1828,9 @@ async def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_u
|
|||
webhook = await Channels.get_webhook_by_id(webhook_id)
|
||||
if not webhook:
|
||||
# Return default favicon if webhook not found
|
||||
# LICENSE covers this Open WebUI fallback logo.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
return FileResponse(f'{STATIC_DIR}/favicon.png')
|
||||
|
||||
if webhook.profile_image_url:
|
||||
|
|
@ -1834,6 +1856,9 @@ async def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_u
|
|||
pass
|
||||
|
||||
# Return default favicon if no profile image
|
||||
# LICENSE covers this Open WebUI fallback logo.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
return FileResponse(f'{STATIC_DIR}/favicon.png')
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
|
||||
|
|
@ -14,7 +11,6 @@ from open_webui.constants import ERROR_MESSAGES
|
|||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import (
|
||||
AggregateChatStats,
|
||||
|
|
@ -28,16 +24,18 @@ from open_webui.models.chats import (
|
|||
ChatStatsExport,
|
||||
ChatTitleIdResponse,
|
||||
ChatUsageStatsListResponse,
|
||||
is_internal_chat,
|
||||
MessageStats,
|
||||
chat_search_content_query,
|
||||
chat_search_terms,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.shared_chats import SharedChatResponse, SharedChats
|
||||
from open_webui.models.tags import TagModel, Tags
|
||||
from open_webui.socket.main import get_event_emitter
|
||||
from open_webui.tasks import has_active_tasks, stop_item_tasks
|
||||
from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.access_control.folders import has_folder_write_access
|
||||
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
|
||||
from open_webui.utils.chat_fork import build_fork_history
|
||||
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
|
||||
|
|
@ -50,8 +48,6 @@ log = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
|
||||
|
||||
CHAT_CONFIG_KEYS = {
|
||||
'CONTEXT_COMPACTION_MODEL': 'chat.context_compaction.model',
|
||||
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
|
||||
|
|
@ -59,9 +55,36 @@ CHAT_CONFIG_KEYS = {
|
|||
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
|
||||
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
|
||||
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
|
||||
'ENABLE_TOOL_PERMISSIONS': 'chat.tool_permissions.enable',
|
||||
}
|
||||
|
||||
|
||||
def overlay_response_streams(chat_data: dict, response_streams: list[dict]) -> dict:
|
||||
if not response_streams:
|
||||
return chat_data
|
||||
|
||||
messages = chat_data.get('chat', {}).get('history', {}).get('messages')
|
||||
if isinstance(messages, dict):
|
||||
for stream in response_streams:
|
||||
message_id = stream.get('message_id')
|
||||
message = messages.get(message_id)
|
||||
if isinstance(message, dict):
|
||||
message['content'] = stream.get('content', '')
|
||||
message['output'] = stream.get('output') or []
|
||||
message['done'] = False
|
||||
|
||||
legacy_messages = chat_data.get('chat', {}).get('messages')
|
||||
if isinstance(legacy_messages, list):
|
||||
streams_by_message_id = {stream.get('message_id'): stream for stream in response_streams}
|
||||
for message in legacy_messages:
|
||||
if isinstance(message, dict) and (stream := streams_by_message_id.get(message.get('id'))):
|
||||
message['content'] = stream.get('content', '')
|
||||
message['output'] = stream.get('output') or []
|
||||
message['done'] = False
|
||||
|
||||
return chat_data
|
||||
|
||||
|
||||
async def get_optional_verified_user(
|
||||
request: Request,
|
||||
response: Response,
|
||||
|
|
@ -141,6 +164,7 @@ class ChatConfigForm(BaseModel):
|
|||
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
|
||||
ENABLE_TOOL_PERMISSIONS: bool = False
|
||||
|
||||
|
||||
class CompactChatForm(BaseModel):
|
||||
|
|
@ -148,38 +172,42 @@ class CompactChatForm(BaseModel):
|
|||
|
||||
|
||||
def chat_search_content_text(text: str) -> str:
|
||||
words = text.lower().strip().split(' ')
|
||||
return ' '.join(word for word in words if not word.startswith(SEARCH_FILTER_PREFIXES)).strip()
|
||||
return chat_search_content_query(text)
|
||||
|
||||
|
||||
def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) -> str | None:
|
||||
if not search_text:
|
||||
return None
|
||||
|
||||
messages = chat.get('messages', [])
|
||||
history = chat.get('history', {})
|
||||
messages = history.get('messages') if isinstance(history, dict) else None
|
||||
if not messages:
|
||||
messages = chat.get('messages', []) or []
|
||||
if isinstance(messages, dict):
|
||||
messages = messages.values()
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
needles = list(dict.fromkeys([search_text, *chat_search_terms(search_text)]))
|
||||
for needle in needles:
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get('content')
|
||||
if not isinstance(content, str):
|
||||
continue
|
||||
content = message.get('content')
|
||||
if not isinstance(content, str):
|
||||
continue
|
||||
|
||||
index = content.lower().find(search_text)
|
||||
if index == -1:
|
||||
continue
|
||||
index = content.lower().find(needle)
|
||||
if index == -1:
|
||||
continue
|
||||
|
||||
start = max(index - max_length // 2, 0)
|
||||
end = min(start + max_length, len(content))
|
||||
if index + len(search_text) > end:
|
||||
end = min(index + len(search_text), len(content))
|
||||
start = max(end - max_length, 0)
|
||||
start = max(index - max_length // 2, 0)
|
||||
end = min(start + max_length, len(content))
|
||||
if index + len(needle) > end:
|
||||
end = min(index + len(needle), len(content))
|
||||
start = max(end - max_length, 0)
|
||||
|
||||
snippet = ' '.join(content[start:end].split())
|
||||
return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}'
|
||||
snippet = ' '.join(content[start:end].split())
|
||||
return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}'
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -449,7 +477,7 @@ def _process_chat_for_export(chat) -> ChatStatsExport | None:
|
|||
history_models[model] = 0
|
||||
history_models[model] += 1
|
||||
except Exception as e:
|
||||
log.debug(f'Error processing message {key}: {e}')
|
||||
log.debug('Error processing message %s: %s', key, e)
|
||||
continue
|
||||
|
||||
# Calculate Averages
|
||||
|
|
@ -615,7 +643,7 @@ async def export_chat_stats(
|
|||
return ChatStatsExportList(items=chat_stats_export_list, total=total, page=page)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f'Error exporting chat stats: {e}')
|
||||
log.debug('Error exporting chat stats: %s', e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
||||
|
|
@ -672,7 +700,7 @@ async def export_single_chat_stats(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Error exporting single chat stats: {e}')
|
||||
log.debug('Error exporting single chat stats: %s', e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
||||
|
|
@ -750,20 +778,11 @@ async def create_new_chat(
|
|||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
# Reject a folder_id that doesn't belong to the caller. Without this the
|
||||
# row is persisted with a dangling foreign reference — no read path
|
||||
# surfaces it across users (all chat reads are user_id-filtered), but
|
||||
# the row state is meaningless and downstream consumers shouldn't have
|
||||
# to assume the column is clean. Also catches non-UUID / nonexistent IDs.
|
||||
if form_data.folder_id is not None:
|
||||
if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
|
||||
# Check shared folder write access
|
||||
shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
|
||||
if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db)
|
||||
|
|
@ -878,7 +897,7 @@ async def search_user_chats(
|
|||
tag_id = words[0].replace('tag:', '')
|
||||
if len(chat_list) == 0:
|
||||
if await Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db):
|
||||
log.debug(f'deleting tag: {tag_id}')
|
||||
log.debug('deleting tag: %s', tag_id)
|
||||
await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
|
||||
|
||||
return await add_active_state_to_chat_list(request, chat_list)
|
||||
|
|
@ -1303,37 +1322,24 @@ async def compact_chat_by_id(
|
|||
|
||||
|
||||
@router.get('/{id}', response_model=ChatResponse | None)
|
||||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
if not chat and user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
|
||||
chat = candidate
|
||||
|
||||
# Access explicitly granted to this user applies to admins too, so an admin
|
||||
# does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off.
|
||||
if not chat:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
|
||||
# Check folder-based access (shared folders)
|
||||
if not chat:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
chat = candidate
|
||||
async def get_chat_by_id(
|
||||
id: str,
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id_for_user(
|
||||
id,
|
||||
user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if chat:
|
||||
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
|
||||
data = overlay_response_streams(
|
||||
data,
|
||||
await get_response_streams_by_chat_id(request.app.state.redis, id),
|
||||
)
|
||||
data['context_usage'] = await get_chat_context_usage(chat)
|
||||
return data
|
||||
|
||||
|
|
@ -1355,15 +1361,8 @@ async def update_chat_by_id(
|
|||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if chat:
|
||||
updated_chat = {**chat.chat, **form_data.chat}
|
||||
if 'history' in form_data.chat:
|
||||
updated_chat['history'] = Chats.merge_history(
|
||||
chat.chat.get('history'),
|
||||
form_data.chat.get('history'),
|
||||
)
|
||||
|
||||
touch = 'history' in form_data.chat or 'messages' in form_data.chat
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db, touch=touch)
|
||||
chat = await Chats.update_chat_by_id(id, form_data.chat, db=db, touch=touch)
|
||||
if form_data.variables is not None:
|
||||
chat = (
|
||||
await Chats.update_chat_variables_by_id(
|
||||
|
|
@ -1377,7 +1376,7 @@ async def update_chat_by_id(
|
|||
|
||||
# Reconcile chat_message rows without inferring deletes from missing IDs.
|
||||
# Message deletion has its own endpoint below.
|
||||
messages = (updated_chat.get('history') or {}).get('messages') or {}
|
||||
messages = ((chat.chat or {}).get('history') or {}).get('messages') or {}
|
||||
if messages:
|
||||
await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
|
||||
|
||||
|
|
@ -2121,17 +2120,12 @@ async def update_chat_folder_id_by_id(
|
|||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if chat:
|
||||
# Same ownership check as the create path — reject foreign / dangling
|
||||
# folder_id values. None is allowed (moves the chat out of any folder).
|
||||
if form_data.folder_id is not None:
|
||||
if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
|
||||
# Check shared folder write access
|
||||
shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
|
||||
if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
# None is allowed: it moves the chat out of any folder.
|
||||
if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db)
|
||||
await publish_event(
|
||||
|
|
@ -2153,10 +2147,15 @@ async def update_chat_folder_id_by_id(
|
|||
|
||||
@router.get('/{id}/tags', response_model=list[TagModel])
|
||||
async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
chat = await Chats.get_chat_by_id_for_user(
|
||||
id,
|
||||
user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if chat:
|
||||
tags = chat.meta.get('tags', [])
|
||||
return await Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db)
|
||||
return await Tags.get_tags_by_ids_and_user_id(tags, chat.user_id, db=db)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from open_webui.events import EVENTS, publish_event
|
|||
from open_webui.models.config import Config
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import get_custom_headers
|
||||
from open_webui.utils.headers import bearer_auth_header, get_custom_headers
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
from open_webui.utils.oauth import (
|
||||
OAuthClientInformationFull,
|
||||
|
|
@ -27,7 +27,6 @@ from open_webui.utils.oauth import (
|
|||
resolve_oauth_client_info,
|
||||
)
|
||||
from open_webui.utils.tools import (
|
||||
bearer_auth_header,
|
||||
get_tool_server_data,
|
||||
get_tool_server_url,
|
||||
set_terminal_servers,
|
||||
|
|
@ -202,7 +201,7 @@ async def register_oauth_client(
|
|||
'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')),
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to register OAuth client: {e}')
|
||||
log.debug('Failed to register OAuth client: %s', e)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Failed to register OAuth client: {e}',
|
||||
|
|
@ -279,7 +278,11 @@ async def set_tool_servers_config(
|
|||
OAuthClientInformationFull(**oauth_client_info),
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to add OAuth client for MCP tool server: {e}')
|
||||
log.debug(
|
||||
'Failed to add OAuth client for MCP tool server %s: %s',
|
||||
server_id,
|
||||
f'{type(e).__name__}: {e}' if str(e) else type(e).__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
await publish_event(
|
||||
|
|
@ -390,7 +393,7 @@ async def verify_terminal_server_connection(
|
|||
pass
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to connect to the terminal server: {e}')
|
||||
log.debug('Failed to connect to the terminal server: %s', e)
|
||||
|
||||
raise HTTPException(status_code=400, detail='Failed to connect to the terminal server')
|
||||
|
||||
|
|
@ -454,7 +457,7 @@ async def put_terminal_server_policy(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to access policy on terminal server: {e}')
|
||||
log.debug('Failed to access policy on terminal server: %s', e)
|
||||
raise HTTPException(status_code=400, detail='Failed to access policy on terminal server')
|
||||
|
||||
|
||||
|
|
@ -491,7 +494,7 @@ async def put_terminal_server_lifecycle(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to access lifecycle on terminal server: {e}')
|
||||
log.debug('Failed to access lifecycle on terminal server: %s', e)
|
||||
raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server')
|
||||
|
||||
|
||||
|
|
@ -538,7 +541,7 @@ async def refresh_terminal_server_terminals(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to refresh terminals: {e}')
|
||||
log.debug('Failed to refresh terminals: %s', e)
|
||||
raise HTTPException(status_code=400, detail='Failed to refresh terminals')
|
||||
|
||||
|
||||
|
|
@ -557,7 +560,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
)
|
||||
discovery_urls = await get_discovery_urls(oauth_server_url)
|
||||
for discovery_url in discovery_urls:
|
||||
log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}')
|
||||
log.debug('Trying to fetch OAuth 2.1 discovery document from %s', discovery_url)
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
|
|
@ -575,7 +578,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
'oauth_server_metadata': oauth_server_metadata.model_dump(mode='json'),
|
||||
}
|
||||
except Exception as e:
|
||||
log.info(f'Failed to parse OAuth 2.1 discovery document: {e}')
|
||||
log.info('Failed to parse OAuth 2.1 discovery document: %s', e)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Failed to parse OAuth 2.1 discovery document from {discovery_url}',
|
||||
|
|
@ -624,7 +627,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
'specs': specs,
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to create MCP client: {e}')
|
||||
log.debug('Failed to create MCP client: %s', e)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Failed to create MCP client',
|
||||
|
|
@ -667,7 +670,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to connect to the tool server: {e}')
|
||||
log.debug('Failed to connect to the tool server: %s', e)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Failed to connect to the tool server',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import asyncio
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
|
@ -28,8 +27,8 @@ from open_webui.events import EVENTS, publish_event
|
|||
from open_webui.internal.db import get_async_db_context, get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.files import (
|
||||
FileForm,
|
||||
FileListResponse,
|
||||
|
|
@ -55,6 +54,7 @@ router = APIRouter()
|
|||
|
||||
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
############################
|
||||
# Upload File
|
||||
|
|
@ -103,7 +103,7 @@ def _cleanup_local_cache(file_path: str) -> None:
|
|||
local_path = os.path.join(UPLOAD_DIR, local_filename)
|
||||
if os.path.isfile(local_path):
|
||||
os.remove(local_path)
|
||||
log.debug(f'Cleaned up local cache: {local_path}')
|
||||
log.debug('Cleaned up local cache: %s', local_path)
|
||||
except OSError as e:
|
||||
log.warning(f'Failed to clean up local cache for {file_path}: {e}')
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ async def process_uploaded_file(
|
|||
# processing (Tools, vision models). Attempting text
|
||||
# extraction causes "Timeout reached while detecting
|
||||
# encoding" errors.
|
||||
log.info(f'Video file detected ({content_type}), skipping text extraction')
|
||||
log.info('Video file detected (%s), skipping text extraction', content_type)
|
||||
await Files.update_file_data_by_id(
|
||||
file_item.id,
|
||||
{'status': 'completed'},
|
||||
|
|
@ -190,7 +190,7 @@ async def process_uploaded_file(
|
|||
# Documents, or media files explicitly enabled for the
|
||||
# configured content extraction engine.
|
||||
if not content_type:
|
||||
log.info(f'File type {file.content_type} is not provided, but trying to process anyway')
|
||||
log.info('File type %s is not provided, but trying to process anyway', file.content_type)
|
||||
await process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=file_item.id),
|
||||
|
|
@ -242,7 +242,7 @@ async def process_uploaded_file(
|
|||
)
|
||||
if not knowledge_file:
|
||||
raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}')
|
||||
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
|
||||
log.info('Linked file %s to knowledge %s', file_item.id, knowledge_id)
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
|
||||
raise
|
||||
|
|
@ -322,12 +322,12 @@ async def upload_file_handler(
|
|||
background_tasks: Optional[BackgroundTasks] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
):
|
||||
log.info(f'file.content_type: {file.content_type} {process}')
|
||||
log.info('file.content_type: %s %s', file.content_type, process)
|
||||
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
metadata = json.loads(metadata)
|
||||
except json.JSONDecodeError:
|
||||
metadata = JSONCodec.loads(metadata)
|
||||
except JSONCodec.JSONDecodeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Invalid metadata format'),
|
||||
|
|
@ -613,9 +613,12 @@ async def get_file_process_status(
|
|||
id: str,
|
||||
stream: bool = Query(False),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
file = await Files.get_file_by_id(id, db=db)
|
||||
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
|
||||
# Database operations manage their own short-lived sessions internally.
|
||||
# Holding a session here would keep a connection for the entire stream
|
||||
# (up to two hours) and exhaust the connection pool under concurrent load.
|
||||
file = await Files.get_file_by_id(id)
|
||||
|
||||
if not file:
|
||||
raise HTTPException(
|
||||
|
|
@ -623,16 +626,13 @@ async def get_file_process_status(
|
|||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):
|
||||
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user):
|
||||
if stream:
|
||||
MAX_FILE_PROCESSING_DURATION = 3600 * 2
|
||||
|
||||
async def event_stream(file_id):
|
||||
# NOTE: We intentionally do NOT capture the request's db session here.
|
||||
# Each poll creates its own short-lived session to avoid holding a
|
||||
# connection for hours. A WebSocket push would be more efficient.
|
||||
for _ in range(MAX_FILE_PROCESSING_DURATION):
|
||||
file_item = await Files.get_file_by_id(file_id) # Creates own session
|
||||
file_item = await Files.get_file_by_id(file_id)
|
||||
if file_item:
|
||||
data = file_item.model_dump().get('data', {})
|
||||
status = data.get('status')
|
||||
|
|
@ -642,14 +642,14 @@ async def get_file_process_status(
|
|||
if status == 'failed':
|
||||
event['error'] = data.get('error')
|
||||
|
||||
yield f'data: {json.dumps(event)}\n\n'
|
||||
yield f'data: {JSONCodec.dumps(event)}\n\n'
|
||||
if status in ('completed', 'failed'):
|
||||
break
|
||||
else:
|
||||
# Legacy
|
||||
break
|
||||
else:
|
||||
yield f'data: {json.dumps({"status": "not_found"})}\n\n'
|
||||
yield f'data: {JSONCodec.dumps({"status": "not_found"})}\n\n'
|
||||
break
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
|
@ -868,7 +868,7 @@ async def get_html_file_content_by_id(
|
|||
|
||||
# Check if the file already exists in the cache
|
||||
if file_path.is_file():
|
||||
log.info(f'file_path: {file_path}')
|
||||
log.info('file_path: %s', file_path)
|
||||
return FileResponse(file_path)
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
@ -1021,7 +1021,7 @@ async def delete_file_by_id(
|
|||
if file.hash:
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash})
|
||||
except Exception as e:
|
||||
log.debug(f'KB embedding cleanup for {knowledge.id}: {e}')
|
||||
log.debug('KB embedding cleanup for %s: %s', knowledge.id, e)
|
||||
|
||||
result = await Files.delete_file_by_id(id, db=db)
|
||||
if result:
|
||||
|
|
|
|||
|
|
@ -97,16 +97,32 @@ async def get_folders(
|
|||
await check_folders_permission(request, user, db=db)
|
||||
|
||||
folders = await Folders.get_folders_by_user_id(user.id, db=db)
|
||||
folder_ids = {folder.id for folder in folders}
|
||||
parent_by_id = {folder.id: folder.parent_id for folder in folders}
|
||||
|
||||
def is_in_parent_cycle(folder_id):
|
||||
seen_ids = {folder_id}
|
||||
current_id = parent_by_id.get(folder_id)
|
||||
while current_id and current_id not in seen_ids:
|
||||
seen_ids.add(current_id)
|
||||
current_id = parent_by_id.get(current_id)
|
||||
return current_id == folder_id
|
||||
|
||||
user_group_ids = None
|
||||
if user.role != 'admin' and any(folder.data and 'files' in folder.data for folder in folders):
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
# Verify folder data integrity
|
||||
folder_list = []
|
||||
for folder in folders:
|
||||
if folder.parent_id and folder.parent_id not in folder_ids:
|
||||
# A missing or looping parent hides the folder from the tree, so put it back at the root
|
||||
if folder.parent_id and (folder.parent_id not in parent_by_id or is_in_parent_cycle(folder.id)):
|
||||
parent_by_id[folder.id] = None
|
||||
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
|
||||
|
||||
if folder.data and 'files' in folder.data:
|
||||
accessible_files = await get_accessible_folder_files(folder.data['files'], user, db=db)
|
||||
accessible_files = await get_accessible_folder_files(
|
||||
folder.data['files'], user, db=db, user_group_ids=user_group_ids
|
||||
)
|
||||
if len(accessible_files) != len(folder.data.get('files', [])):
|
||||
folder.data['files'] = accessible_files
|
||||
await Folders.update_folder_by_id_and_user_id(
|
||||
|
|
@ -233,43 +249,35 @@ async def get_shared_folders(
|
|||
|
||||
folder_perms = await Folders.get_shared_folder_ids_for_user(user.id, group_ids, db=db)
|
||||
|
||||
# Filter out folders owned by the user
|
||||
results = []
|
||||
owner_cache = {}
|
||||
for folder_id, permission in folder_perms.items():
|
||||
folder = await Folders.get_folder_by_id(folder_id, db=db)
|
||||
if not folder or folder.user_id == user.id:
|
||||
continue
|
||||
folders = await Folders.get_folders_by_ids(list(folder_perms.keys()), db=db)
|
||||
shared_folders = [folder for folder in folders if folder.user_id != user.id]
|
||||
|
||||
# Get owner name (cached)
|
||||
if folder.user_id not in owner_cache:
|
||||
owner = await Users.get_user_by_id(folder.user_id, db=db)
|
||||
owner_cache[folder.user_id] = owner.name if owner else 'Unknown'
|
||||
owners = await Users.get_users_by_user_ids([folder.user_id for folder in shared_folders], db=db)
|
||||
owner_names = {owner.id: owner.name for owner in owners}
|
||||
|
||||
results.append(
|
||||
{
|
||||
**folder.model_dump(),
|
||||
'owner_name': owner_cache[folder.user_id],
|
||||
'permission': permission,
|
||||
}
|
||||
)
|
||||
results = [
|
||||
{
|
||||
**folder.model_dump(),
|
||||
'owner_name': owner_names.get(folder.user_id, 'Unknown'),
|
||||
'permission': folder_perms[folder.id],
|
||||
}
|
||||
for folder in shared_folders
|
||||
]
|
||||
|
||||
# Also include child folders of shared folders (inheritance)
|
||||
shared_root_ids = {r['id'] for r in results}
|
||||
for root_id in list(shared_root_ids):
|
||||
root_folder = await Folders.get_folder_by_id(root_id, db=db)
|
||||
if root_folder:
|
||||
children = await Folders.get_children_folders_by_id_and_user_id(root_id, root_folder.user_id, db=db)
|
||||
if children:
|
||||
for child in children:
|
||||
if child.id not in {r['id'] for r in results}:
|
||||
results.append(
|
||||
{
|
||||
**child.model_dump(),
|
||||
'owner_name': owner_cache.get(child.user_id, 'Unknown'),
|
||||
'permission': folder_perms.get(root_id, 'read'),
|
||||
}
|
||||
)
|
||||
seen_ids = {folder.id for folder in shared_folders}
|
||||
for folder in shared_folders:
|
||||
children = await Folders.get_children_folders_by_id_and_user_id(folder.id, folder.user_id, db=db)
|
||||
for child in children or []:
|
||||
if child.id not in seen_ids:
|
||||
seen_ids.add(child.id)
|
||||
results.append(
|
||||
{
|
||||
**child.model_dump(),
|
||||
'owner_name': owner_names.get(child.user_id, 'Unknown'),
|
||||
'permission': folder_perms[folder.id],
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
|
@ -399,6 +407,14 @@ async def update_folder_parent_id_by_id(
|
|||
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'),
|
||||
)
|
||||
|
||||
if form_data.parent_id and form_data.parent_id in await Folders.get_folder_ids_by_id_and_user_id_in_subtree(
|
||||
id, user.id, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Cannot move a folder into itself or one of its subfolders'),
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await Folders.update_folder_parent_id_by_id_and_user_id(id, user.id, form_data.parent_id, db=db)
|
||||
await publish_event(
|
||||
|
|
|
|||
|
|
@ -593,6 +593,9 @@ async def get_function_user_valves_spec_by_id(
|
|||
):
|
||||
function = await Functions.get_function_by_id(id, db=db)
|
||||
if function:
|
||||
if not function.is_active:
|
||||
return None
|
||||
|
||||
function_module, function_type, frontmatter = await get_function_module_from_cache(request, id)
|
||||
|
||||
if hasattr(function_module, 'UserValves'):
|
||||
|
|
@ -620,6 +623,12 @@ async def update_function_user_valves_by_id(
|
|||
function = await Functions.get_function_by_id(id, db=db)
|
||||
|
||||
if function:
|
||||
if not function.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Function is not active',
|
||||
)
|
||||
|
||||
function_module, function_type, frontmatter = await get_function_module_from_cache(request, id)
|
||||
|
||||
if hasattr(function_module, 'UserValves'):
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
|
|
@ -17,7 +16,6 @@ import aiofiles
|
|||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image, ImageOps
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
ENABLE_OPENAI_IMAGE_EDIT_NORMALIZATION,
|
||||
|
|
@ -43,7 +41,9 @@ from open_webui.utils.images.comfyui import (
|
|||
comfyui_edit_image,
|
||||
comfyui_upload_image,
|
||||
)
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.session_pool import get_session
|
||||
from PIL import Image, ImageOps
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ def normalize_openai_edit_image_data_url(data_url: str) -> str:
|
|||
normalized_image = base64.b64encode(output.getvalue()).decode('utf-8')
|
||||
return f'data:image/jpeg;base64,{normalized_image}'
|
||||
except Exception as e:
|
||||
log.debug(f'Image edit normalization skipped: {e}')
|
||||
log.debug('Image edit normalization skipped: %s', e)
|
||||
|
||||
return data_url
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ def get_image_file_item(base64_string, param_name='image'):
|
|||
|
||||
|
||||
async def set_image_model(request: Request, model: str):
|
||||
log.info(f'Setting image model to {model}')
|
||||
log.info('Setting image model to %s', model)
|
||||
await Config.upsert({'image_generation.model': model})
|
||||
image_config = await get_image_config()
|
||||
if image_config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']:
|
||||
|
|
@ -194,7 +194,7 @@ async def set_image_model(request: Request, model: str):
|
|||
) as r:
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
log.debug(f'{e}')
|
||||
log.debug('%s', e)
|
||||
|
||||
return image_config.IMAGE_GENERATION_MODEL
|
||||
|
||||
|
|
@ -389,7 +389,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
|
|||
) as r:
|
||||
info = await r.json()
|
||||
|
||||
workflow = json.loads(image_config.COMFYUI_WORKFLOW)
|
||||
workflow = JSONCodec.loads(image_config.COMFYUI_WORKFLOW)
|
||||
model_node_id = None
|
||||
|
||||
for node in image_config.COMFYUI_WORKFLOW_NODES:
|
||||
|
|
@ -488,7 +488,7 @@ async def get_image_data(data: str, headers=None, trusted_base_url: str | None =
|
|||
# ENABLE_LOCAL_WEB_FETCH hammer and a blanket trust flag
|
||||
# that would follow arbitrary redirects.
|
||||
if trusted_base_url and _is_same_origin(data, trusted_base_url):
|
||||
log.debug(f'Skipping URL validation for trusted backend: {data}')
|
||||
log.debug('Skipping URL validation for trusted backend: %s', data)
|
||||
else:
|
||||
await asyncio.to_thread(validate_url, data)
|
||||
session = await get_session()
|
||||
|
|
@ -762,7 +762,7 @@ async def image_generations(
|
|||
image_config.COMFYUI_BASE_URL,
|
||||
image_config.COMFYUI_API_KEY,
|
||||
)
|
||||
log.debug(f'res: {res}')
|
||||
log.debug('res: %s', res)
|
||||
|
||||
images = []
|
||||
|
||||
|
|
@ -817,7 +817,7 @@ async def image_generations(
|
|||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
res = await r.json(content_type=None)
|
||||
log.debug(f'res: {res}')
|
||||
log.debug('res: %s', res)
|
||||
|
||||
images = []
|
||||
|
||||
|
|
@ -1008,7 +1008,7 @@ async def image_edits(
|
|||
form = aiohttp.FormData()
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
form.add_field(key, json.dumps(value))
|
||||
form.add_field(key, JSONCodec.dumps(value))
|
||||
else:
|
||||
form.add_field(key, str(value))
|
||||
for param_name, (filename, file_obj, content_type_val) in files:
|
||||
|
|
@ -1119,7 +1119,7 @@ async def image_edits(
|
|||
)
|
||||
comfyui_images.append(res.get('name', file_item[1][0]))
|
||||
except Exception as e:
|
||||
log.debug(f'Error uploading images to ComfyUI: {e}')
|
||||
log.debug('Error uploading images to ComfyUI: %s', e)
|
||||
raise Exception('Failed to upload images to ComfyUI.')
|
||||
|
||||
data = {
|
||||
|
|
@ -1148,7 +1148,7 @@ async def image_edits(
|
|||
image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
image_config.IMAGES_EDIT_COMFYUI_API_KEY,
|
||||
)
|
||||
log.debug(f'res: {res}')
|
||||
log.debug('res: %s', res)
|
||||
|
||||
image_urls = set()
|
||||
for image in res['data']:
|
||||
|
|
@ -1160,7 +1160,7 @@ async def image_edits(
|
|||
if output_type_urls:
|
||||
image_urls = output_type_urls
|
||||
|
||||
log.debug(f'Image URLs: {image_urls}')
|
||||
log.debug('Image URLs: %s', image_urls)
|
||||
images = []
|
||||
|
||||
for image_url in image_urls:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -12,7 +11,11 @@ from urllib.parse import quote
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, RAG_EMBEDDING_CONTENT_PREFIX
|
||||
from open_webui.config import (
|
||||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
ENABLE_KNOWLEDGE_FILE_RETENTION,
|
||||
RAG_EMBEDDING_CONTENT_PREFIX,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
|
|
@ -31,8 +34,8 @@ from open_webui.models.knowledge import (
|
|||
KnowledgeUserResponse,
|
||||
)
|
||||
from open_webui.models.models import ModelForm, Models
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.external import retrieve_external_knowledge, retrieve_external_knowledge_for_connection
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.routers.retrieval import (
|
||||
BatchProcessFilesForm,
|
||||
ProcessFileForm,
|
||||
|
|
@ -43,6 +46,7 @@ from open_webui.storage.provider import Storage
|
|||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -56,6 +60,26 @@ router = APIRouter()
|
|||
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
async def delete_file_resource(file: FileModel, db: AsyncSession) -> bool:
|
||||
try:
|
||||
file_collection = f'file-{file.id}'
|
||||
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
|
||||
except Exception as e:
|
||||
log.debug('This was most likely caused by bypassing embedding processing')
|
||||
log.debug(e)
|
||||
|
||||
result = await Files.delete_file_by_id(file.id, db=db)
|
||||
if result and file.path:
|
||||
try:
|
||||
await asyncio.to_thread(Storage.delete_file, file.path)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
############################
|
||||
# Knowledge Base Embedding
|
||||
############################
|
||||
|
|
@ -103,7 +127,7 @@ async def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bo
|
|||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to remove embedding for {knowledge_base_id}: {e}')
|
||||
log.debug('Failed to remove embedding for %s: %s', knowledge_base_id, e)
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -351,7 +375,7 @@ async def reindex_knowledge_files(
|
|||
failed_files = []
|
||||
start_time = time.monotonic()
|
||||
|
||||
log.info(f'Starting reindexing for {len(knowledge_bases)} knowledge bases ({total_files} files)')
|
||||
log.info('Starting reindexing for %s knowledge bases (%s files)', len(knowledge_bases), total_files)
|
||||
|
||||
for kb_idx, (knowledge_base, files) in enumerate(knowledge_base_files, start=1):
|
||||
try:
|
||||
|
|
@ -371,11 +395,22 @@ async def reindex_knowledge_files(
|
|||
eta = f', ETA: {round(elapsed / (processed_files - 1) * remaining_files)}s'
|
||||
|
||||
log.info(
|
||||
f'Reindexing knowledge base {kb_idx}/{len(knowledge_bases)} '
|
||||
f'file {processed_files}/{total_files}{eta}: {file.filename}'
|
||||
'Reindexing knowledge base %s/%s file %s/%s%s: %s',
|
||||
kb_idx,
|
||||
len(knowledge_bases),
|
||||
processed_files,
|
||||
total_files,
|
||||
eta,
|
||||
file.filename,
|
||||
)
|
||||
|
||||
try:
|
||||
# Force the KB add path to use stored SQL content instead of stale file-{id} chunks.
|
||||
# process_file recreates file-{id} only when that stored content exists.
|
||||
file_collection = f'file-{file.id}'
|
||||
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
|
||||
|
||||
await process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=file.id, collection_name=knowledge_base.id),
|
||||
|
|
@ -397,7 +432,7 @@ async def reindex_knowledge_files(
|
|||
for failed in failed_files:
|
||||
log.warning(f'File ID: {failed["file_id"]}, Error: {failed["error"]}')
|
||||
|
||||
log.info(f'Reindexing completed in {round(time.monotonic() - start_time)}s.')
|
||||
log.info('Reindexing completed in %ss.', round(time.monotonic() - start_time))
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.KNOWLEDGE_REINDEXED,
|
||||
|
|
@ -426,14 +461,18 @@ async def reindex_knowledge_base_metadata_embeddings(
|
|||
this entire operation would exhaust the connection pool.
|
||||
"""
|
||||
knowledge_bases = await Knowledges.get_knowledge_bases()
|
||||
log.info(f'Reindexing embeddings for {len(knowledge_bases)} knowledge bases')
|
||||
log.info('Reindexing embeddings for %s knowledge bases', len(knowledge_bases))
|
||||
try:
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=KNOWLEDGE_BASES_COLLECTION)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
|
||||
success_count = 0
|
||||
for kb in knowledge_bases:
|
||||
if await embed_knowledge_base_metadata(request, kb.id, kb.name, kb.description):
|
||||
success_count += 1
|
||||
|
||||
log.info(f'Embedding reindex complete: {success_count}/{len(knowledge_bases)}')
|
||||
log.info('Embedding reindex complete: %s/%s', success_count, len(knowledge_bases))
|
||||
return {'total': len(knowledge_bases), 'success': success_count}
|
||||
|
||||
|
||||
|
|
@ -503,7 +542,7 @@ EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY = 'external_knowledge.connections'
|
|||
EXTERNAL_KNOWLEDGE_PROVIDERS = {'qdrant', 'milvus', 'pgvector'}
|
||||
|
||||
|
||||
def _validate_external_connection_form(form_data: ExternalKnowledgeConnectionForm) -> tuple[str, dict]:
|
||||
def _get_external_connection_provider_and_config(form_data: ExternalKnowledgeConnectionForm) -> tuple[str, dict]:
|
||||
provider = form_data.provider.lower().strip()
|
||||
if provider not in EXTERNAL_KNOWLEDGE_PROVIDERS:
|
||||
raise HTTPException(
|
||||
|
|
@ -525,13 +564,13 @@ def _validate_external_connection_form(form_data: ExternalKnowledgeConnectionFor
|
|||
return provider, {key: value for key, value in config.items() if key in allowed_config_keys}
|
||||
|
||||
|
||||
def _external_auth_config(provider: str, incoming: Optional[dict], existing: Optional[dict] = None) -> dict:
|
||||
def _get_external_auth_config(provider: str, incoming: Optional[dict], existing: Optional[dict] = None) -> dict:
|
||||
if provider == 'pgvector':
|
||||
return {}
|
||||
return existing if incoming is None else incoming or {}
|
||||
|
||||
|
||||
def _normalize_external_source(source: ExternalKnowledgeSourceForm, provider: str) -> ExternalKnowledgeSourceForm:
|
||||
def _get_normalized_external_source(source: ExternalKnowledgeSourceForm, provider: str) -> ExternalKnowledgeSourceForm:
|
||||
source.type = (source.type or 'collection').strip()
|
||||
source.name = source.name.strip()
|
||||
|
||||
|
|
@ -562,7 +601,7 @@ def _normalize_external_source(source: ExternalKnowledgeSourceForm, provider: st
|
|||
return source
|
||||
|
||||
|
||||
def _sanitize_external_connection(connection: dict) -> dict:
|
||||
def _get_sanitized_external_connection(connection: dict) -> dict:
|
||||
sanitized = {**connection}
|
||||
sanitized.pop('auth_config', None)
|
||||
sanitized['auth_configured'] = bool(connection.get('auth_config'))
|
||||
|
|
@ -577,17 +616,17 @@ async def _set_external_connections(connections: list[dict]) -> None:
|
|||
await Config.upsert({EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY: connections})
|
||||
|
||||
|
||||
def _external_connection_dict(
|
||||
def _get_external_connection_from_form(
|
||||
form_data: ExternalKnowledgeConnectionForm, user_id: str, id: Optional[str] = None
|
||||
) -> dict:
|
||||
provider, config = _validate_external_connection_form(form_data)
|
||||
provider, config = _get_external_connection_provider_and_config(form_data)
|
||||
now = int(time.time())
|
||||
return {
|
||||
'id': id or str(uuid.uuid4()),
|
||||
'name': form_data.name.strip(),
|
||||
'provider': provider,
|
||||
'endpoint': form_data.endpoint.strip(),
|
||||
'auth_config': _external_auth_config(provider, form_data.auth_config),
|
||||
'auth_config': _get_external_auth_config(provider, form_data.auth_config),
|
||||
'config': config,
|
||||
'capabilities': form_data.capabilities or {'retrieve': True},
|
||||
'health': None,
|
||||
|
|
@ -598,17 +637,17 @@ def _external_connection_dict(
|
|||
}
|
||||
|
||||
|
||||
def _external_connection_update_dict(
|
||||
def _get_external_connection_update_from_form(
|
||||
form_data: ExternalKnowledgeConnectionForm,
|
||||
existing: dict,
|
||||
) -> dict:
|
||||
provider, config = _validate_external_connection_form(form_data)
|
||||
provider, config = _get_external_connection_provider_and_config(form_data)
|
||||
return {
|
||||
**existing,
|
||||
'name': form_data.name.strip(),
|
||||
'provider': provider,
|
||||
'endpoint': form_data.endpoint.strip(),
|
||||
'auth_config': _external_auth_config(provider, form_data.auth_config, existing.get('auth_config')) or {},
|
||||
'auth_config': _get_external_auth_config(provider, form_data.auth_config, existing.get('auth_config')) or {},
|
||||
'config': config,
|
||||
'capabilities': form_data.capabilities or {'retrieve': True},
|
||||
'enabled': form_data.enabled,
|
||||
|
|
@ -616,12 +655,14 @@ def _external_connection_update_dict(
|
|||
}
|
||||
|
||||
|
||||
async def _get_external_connection(id: str) -> Optional[dict]:
|
||||
async def _get_external_connection_by_id(id: str) -> Optional[dict]:
|
||||
connections = await _get_external_connections()
|
||||
return next((connection for connection in connections if connection.get('id') == id), None)
|
||||
|
||||
|
||||
async def _count_external_connection_mappings(connection_id: str, db: Optional[AsyncSession] = None) -> int:
|
||||
async def _get_knowledge_base_count_for_external_connection(
|
||||
connection_id: str, db: Optional[AsyncSession] = None
|
||||
) -> int:
|
||||
count = 0
|
||||
for knowledge in await Knowledges.get_knowledge_bases(db=db):
|
||||
if (knowledge.meta or {}).get('external', {}).get('connection_id') == connection_id:
|
||||
|
|
@ -630,11 +671,8 @@ async def _count_external_connection_mappings(connection_id: str, db: Optional[A
|
|||
|
||||
|
||||
@router.get('/external/connections', response_model=ExternalKnowledgeConnectionListResponse)
|
||||
async def get_external_knowledge_connections(
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
connections = [_sanitize_external_connection(connection) for connection in await _get_external_connections()]
|
||||
async def get_external_knowledge_connections(user=Depends(get_admin_user)):
|
||||
connections = [_get_sanitized_external_connection(connection) for connection in await _get_external_connections()]
|
||||
return ExternalKnowledgeConnectionListResponse(items=connections, total=len(connections))
|
||||
|
||||
|
||||
|
|
@ -645,10 +683,10 @@ async def create_external_knowledge_connection(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
connections = await _get_external_connections()
|
||||
connection = _external_connection_dict(form_data, user.id)
|
||||
connection = _get_external_connection_from_form(form_data, user.id)
|
||||
connections.append(connection)
|
||||
await _set_external_connections(connections)
|
||||
sanitized = _sanitize_external_connection(connection)
|
||||
sanitized = _get_sanitized_external_connection(connection)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.KNOWLEDGE_EXTERNAL_CONNECTION_CREATED,
|
||||
|
|
@ -663,12 +701,11 @@ async def create_external_knowledge_connection(
|
|||
async def get_external_knowledge_connection(
|
||||
id: str,
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
connection = await _get_external_connection(id)
|
||||
connection = await _get_external_connection_by_id(id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
return _sanitize_external_connection(connection)
|
||||
return _get_sanitized_external_connection(connection)
|
||||
|
||||
|
||||
@router.patch('/external/connections/{id}', response_model=dict)
|
||||
|
|
@ -683,10 +720,10 @@ async def update_external_knowledge_connection(
|
|||
if idx is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
connection = _external_connection_update_dict(form_data, connections[idx])
|
||||
connection = _get_external_connection_update_from_form(form_data, connections[idx])
|
||||
connections[idx] = connection
|
||||
await _set_external_connections(connections)
|
||||
sanitized = _sanitize_external_connection(connection)
|
||||
sanitized = _get_sanitized_external_connection(connection)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.KNOWLEDGE_EXTERNAL_CONNECTION_UPDATED,
|
||||
|
|
@ -704,11 +741,11 @@ async def delete_external_knowledge_connection(
|
|||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
connection = await _get_external_connection(id)
|
||||
connection = await _get_external_connection_by_id(id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if await _count_external_connection_mappings(id, db=db) > 0:
|
||||
if await _get_knowledge_base_count_for_external_connection(id, db=db) > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='External connection is still used by knowledge bases.',
|
||||
|
|
@ -730,9 +767,8 @@ async def delete_external_knowledge_connection(
|
|||
async def test_external_knowledge_connection(
|
||||
id: str,
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
connection = await _get_external_connection(id)
|
||||
connection = await _get_external_connection_by_id(id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
|
@ -751,7 +787,7 @@ async def test_external_knowledge_connection(
|
|||
return health
|
||||
|
||||
|
||||
async def _test_external_source_definition(
|
||||
async def _get_external_source_test_result(
|
||||
request: Request,
|
||||
connection: dict,
|
||||
source: ExternalKnowledgeSourceForm,
|
||||
|
|
@ -762,7 +798,7 @@ async def _test_external_source_definition(
|
|||
if not query.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query is required.')
|
||||
|
||||
source = _normalize_external_source(source, connection.get('provider'))
|
||||
source = _get_normalized_external_source(source, connection.get('provider'))
|
||||
test_knowledge = KnowledgeResponse(
|
||||
id='external-test',
|
||||
user_id=user.id,
|
||||
|
|
@ -805,14 +841,14 @@ async def test_external_knowledge_source(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
if form_data.connection_id:
|
||||
existing_connection = await _get_external_connection(form_data.connection_id)
|
||||
existing_connection = await _get_external_connection_by_id(form_data.connection_id)
|
||||
if not existing_connection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='External connection not found.')
|
||||
connection = _external_connection_update_dict(form_data.connection, existing_connection)
|
||||
connection = _get_external_connection_update_from_form(form_data.connection, existing_connection)
|
||||
else:
|
||||
connection = _external_connection_dict(form_data.connection, user.id, id='external-test')
|
||||
connection = _get_external_connection_from_form(form_data.connection, user.id, id='external-test')
|
||||
|
||||
return await _test_external_source_definition(
|
||||
return await _get_external_source_test_result(
|
||||
request,
|
||||
connection,
|
||||
form_data.source,
|
||||
|
|
@ -828,14 +864,13 @@ async def test_external_knowledge_retrieval(
|
|||
id: str,
|
||||
form_data: ExternalKnowledgeRetrieveTestForm,
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
connection = await _get_external_connection(id)
|
||||
connection = await _get_external_connection_by_id(id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
source = form_data.source or ExternalKnowledgeSourceForm(name='test', config={'content_field': 'payload.text'})
|
||||
return await _test_external_source_definition(request, connection, source, form_data.query, form_data.count, user)
|
||||
return await _get_external_source_test_result(request, connection, source, form_data.query, form_data.count, user)
|
||||
|
||||
|
||||
@router.post('/external/knowledge/create', response_model=KnowledgeResponse | None)
|
||||
|
|
@ -845,12 +880,12 @@ async def create_external_knowledge(
|
|||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
connection = await _get_external_connection(form_data.connection_id)
|
||||
connection = await _get_external_connection_by_id(form_data.connection_id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
if not form_data.name.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge name is required.')
|
||||
source = _normalize_external_source(form_data.source, connection.get('provider'))
|
||||
source = _get_normalized_external_source(form_data.source, connection.get('provider'))
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
|
|
@ -898,9 +933,9 @@ async def create_external_knowledge_source(
|
|||
if not form_data.name.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge name is required.')
|
||||
|
||||
connection = _external_connection_dict(form_data.connection, user.id)
|
||||
source = _normalize_external_source(form_data.source, connection.get('provider'))
|
||||
test_result = await _test_external_source_definition(
|
||||
connection = _get_external_connection_from_form(form_data.connection, user.id)
|
||||
source = _get_normalized_external_source(form_data.source, connection.get('provider'))
|
||||
test_result = await _get_external_source_test_result(
|
||||
request,
|
||||
connection,
|
||||
source,
|
||||
|
|
@ -974,9 +1009,9 @@ async def update_external_knowledge_source(
|
|||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='External connection not found.')
|
||||
|
||||
existing_connection = connections[idx]
|
||||
connection = _external_connection_update_dict(form_data.connection, existing_connection)
|
||||
source = _normalize_external_source(form_data.source, connection.get('provider'))
|
||||
test_result = await _test_external_source_definition(
|
||||
connection = _get_external_connection_update_from_form(form_data.connection, existing_connection)
|
||||
source = _get_normalized_external_source(form_data.source, connection.get('provider'))
|
||||
test_result = await _get_external_source_test_result(
|
||||
request,
|
||||
connection,
|
||||
source,
|
||||
|
|
@ -1233,7 +1268,6 @@ async def get_pending_knowledge_files(
|
|||
id: str,
|
||||
stream: bool = Query(False),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Return files that are being processed for this knowledge base but not yet linked.
|
||||
|
||||
|
|
@ -1246,7 +1280,11 @@ async def get_pending_knowledge_files(
|
|||
When ``stream=true``, returns an SSE stream that polls every 3 seconds
|
||||
and emits the current pending file list. Closes when no files remain.
|
||||
"""
|
||||
knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db)
|
||||
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
|
||||
# Database operations manage their own short-lived sessions internally.
|
||||
# Holding a session here would keep a connection for the entire stream
|
||||
# (up to an hour) and exhaust the connection pool under concurrent load.
|
||||
knowledge = await Knowledges.get_knowledge_by_id(id=id)
|
||||
if not knowledge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -1261,7 +1299,6 @@ async def get_pending_knowledge_files(
|
|||
resource_type='knowledge',
|
||||
resource_id=knowledge.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -1270,14 +1307,14 @@ async def get_pending_knowledge_files(
|
|||
)
|
||||
|
||||
if not stream:
|
||||
return await Files.get_pending_files_for_knowledge(id, db=db)
|
||||
return await Files.get_pending_files_for_knowledge(id)
|
||||
|
||||
async def event_stream(knowledge_id: str):
|
||||
MAX_POLL_DURATION = 3600 # 1 hour max
|
||||
for _ in range(MAX_POLL_DURATION // 3):
|
||||
pending = await Files.get_pending_files_for_knowledge(knowledge_id)
|
||||
data = [f.model_dump() for f in pending]
|
||||
yield f'data: {json.dumps(data)}\n\n'
|
||||
yield f'data: {JSONCodec.dumps(data)}\n\n'
|
||||
if len(pending) == 0:
|
||||
break
|
||||
await asyncio.sleep(3)
|
||||
|
|
@ -1558,7 +1595,7 @@ async def remove_file_from_knowledge_by_id(
|
|||
request: Request,
|
||||
id: str,
|
||||
form_data: KnowledgeFileIdForm,
|
||||
delete_file: bool = Query(True),
|
||||
delete_file: bool = Query(not ENABLE_KNOWLEDGE_FILE_RETENTION),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -1619,18 +1656,7 @@ async def remove_file_from_knowledge_by_id(
|
|||
|
||||
# Anyone with write permission or higher can delete files
|
||||
if delete_file and (file.user_id == user.id or user.role == 'admin'):
|
||||
try:
|
||||
# Remove the file's collection from vector database
|
||||
file_collection = f'file-{form_data.file_id}'
|
||||
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
|
||||
except Exception as e:
|
||||
log.debug('This was most likely caused by bypassing embedding processing')
|
||||
log.debug(e)
|
||||
pass
|
||||
|
||||
# Delete file from database
|
||||
await Files.delete_file_by_id(form_data.file_id, db=db)
|
||||
await delete_file_resource(file, db)
|
||||
|
||||
if knowledge:
|
||||
response = KnowledgeFilesResponse(
|
||||
|
|
@ -1687,11 +1713,11 @@ async def delete_knowledge_by_id(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
log.info(f'Deleting knowledge base: {id} (name: {knowledge.name})')
|
||||
log.info('Deleting knowledge base: %s (name: %s)', id, knowledge.name)
|
||||
|
||||
# Get all models
|
||||
models = await Models.get_all_models(db=db)
|
||||
log.info(f'Found {len(models)} models to check for knowledge base {id}')
|
||||
log.info('Found %s models to check for knowledge base %s', len(models), id)
|
||||
|
||||
# Update models that reference this knowledge base
|
||||
for model in models:
|
||||
|
|
@ -1702,7 +1728,7 @@ async def delete_knowledge_by_id(
|
|||
|
||||
# If the knowledge list changed, update the model
|
||||
if len(updated_knowledge) != len(knowledge_list):
|
||||
log.info(f'Updating model {model.id} to remove knowledge base {id}')
|
||||
log.info('Updating model %s to remove knowledge base %s', model.id, id)
|
||||
model.meta.knowledge = updated_knowledge
|
||||
model_form = ModelForm(**model.model_dump())
|
||||
await Models.update_model_by_id(model.id, model_form, db=db)
|
||||
|
|
@ -1710,7 +1736,12 @@ async def delete_knowledge_by_id(
|
|||
# Clean up vector DB
|
||||
if is_external_knowledge(knowledge):
|
||||
connection_id = (knowledge.meta or {}).get('external', {}).get('connection_id')
|
||||
if connection_id:
|
||||
# Connections are admin-owned and shared across knowledge bases
|
||||
if (
|
||||
connection_id
|
||||
and user.role == 'admin'
|
||||
and await _get_knowledge_base_count_for_external_connection(connection_id, db=db) <= 1
|
||||
):
|
||||
connections = [
|
||||
connection for connection in await _get_external_connections() if connection.get('id') != connection_id
|
||||
]
|
||||
|
|
@ -1775,12 +1806,18 @@ async def reset_knowledge_by_id(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
files = await Knowledges.get_files_by_id(id, db=db) if not ENABLE_KNOWLEDGE_FILE_RETENTION else []
|
||||
|
||||
try:
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
pass
|
||||
|
||||
for file in files:
|
||||
if file.user_id == user.id or user.role == 'admin':
|
||||
await delete_file_resource(file, db)
|
||||
|
||||
knowledge = await Knowledges.reset_knowledge_by_id(id=id, include_directories=include_directories, db=db)
|
||||
if knowledge:
|
||||
await publish_event(
|
||||
|
|
@ -1955,19 +1992,13 @@ async def sync_knowledge_cleanup(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
collection_name = f'file-{file_id}'
|
||||
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name):
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if file.user_id == user.id or user.role == 'admin':
|
||||
await Files.delete_file_by_id(file_id, db=db)
|
||||
try:
|
||||
await asyncio.to_thread(Storage.delete_file, file.path)
|
||||
except Exception:
|
||||
pass
|
||||
linked_knowledges = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
if (
|
||||
not ENABLE_KNOWLEDGE_FILE_RETENTION
|
||||
and not linked_knowledges
|
||||
and (file.user_id == user.id or user.role == 'admin')
|
||||
):
|
||||
await delete_file_resource(file, db)
|
||||
|
||||
# ── Remove orphaned directories (children before parents) ──
|
||||
for dir_id in reversed(form_data.dir_ids):
|
||||
|
|
@ -2022,7 +2053,7 @@ async def add_files_to_knowledge_batch(
|
|||
)
|
||||
|
||||
# Batch-fetch all files to avoid N+1 queries
|
||||
log.info(f'files/batch/add - {len(form_data)} files')
|
||||
log.info('files/batch/add - %s files', len(form_data))
|
||||
file_ids = [form.file_id for form in form_data]
|
||||
files = await Files.get_files_by_ids(file_ids, db=db)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ from open_webui.events import EVENTS, publish_event
|
|||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.memories import Memories, MemoryModel
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.memory import (
|
||||
clean_memory_content,
|
||||
clean_memory_path,
|
||||
|
|
@ -124,6 +125,61 @@ def _memory_metadata(memory: MemoryModel) -> dict:
|
|||
}
|
||||
|
||||
|
||||
async def reindex_memory_vectors_for_user(
|
||||
request: Request,
|
||||
user_id: str,
|
||||
memories: list[MemoryModel] | None = None,
|
||||
user=None,
|
||||
) -> int:
|
||||
collection_name = f'user-memory-{user_id}'
|
||||
try:
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
|
||||
memories = memories if memories is not None else await Memories.get_memories_by_user_id(user_id)
|
||||
memories = memories or []
|
||||
if not memories:
|
||||
return 0
|
||||
|
||||
vectors = await asyncio.gather(
|
||||
*[
|
||||
request.app.state.EMBEDDING_FUNCTION(
|
||||
memory_vector_text(memory.content, memory.path),
|
||||
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
|
||||
user=user,
|
||||
)
|
||||
for memory in memories
|
||||
]
|
||||
)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=collection_name,
|
||||
items=[
|
||||
{
|
||||
'id': memory.id,
|
||||
'text': memory_vector_text(memory.content, memory.path),
|
||||
'vector': vectors[idx],
|
||||
'metadata': _memory_metadata(memory),
|
||||
}
|
||||
for idx, memory in enumerate(memories)
|
||||
],
|
||||
)
|
||||
return len(memories)
|
||||
|
||||
|
||||
async def upsert_memory_vectors_or_reindex(request: Request, user, items: list[dict]) -> None:
|
||||
try:
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(collection_name=f'user-memory-{user.id}', items=items)
|
||||
except Exception as e:
|
||||
message = str(e).lower()
|
||||
if 'dimension' not in message or 'embedding' not in message:
|
||||
raise
|
||||
|
||||
log.warning('Memory vector dimension mismatch for user %s; reindexing memory vectors.', user.id)
|
||||
await reindex_memory_vectors_for_user(request, user.id, user=user)
|
||||
|
||||
|
||||
@router.post('/add', response_model=MemoryModel | None)
|
||||
async def add_memory(
|
||||
request: Request,
|
||||
|
|
@ -152,9 +208,10 @@ async def add_memory(
|
|||
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
|
||||
)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
items=[
|
||||
await upsert_memory_vectors_or_reindex(
|
||||
request,
|
||||
user,
|
||||
[
|
||||
{
|
||||
'id': memory.id,
|
||||
'text': memory_vector_text(memory.content, memory.path),
|
||||
|
|
@ -226,7 +283,7 @@ async def update_memories(
|
|||
response.append(result)
|
||||
|
||||
if upsert_items:
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(collection_name=f'user-memory-{user.id}', items=upsert_items)
|
||||
await upsert_memory_vectors_or_reindex(request, user, upsert_items)
|
||||
|
||||
if delete_ids:
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=delete_ids)
|
||||
|
|
@ -386,8 +443,42 @@ async def read_memory_path(
|
|||
|
||||
|
||||
############################
|
||||
# ResetMemoryFromVectorDB
|
||||
# ReindexMemoryVectorDB
|
||||
############################
|
||||
@router.post('/reindex')
|
||||
async def reindex_memories_from_vector_db(
|
||||
request: Request,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
memories = await Memories.get_memories()
|
||||
memories = memories or []
|
||||
memories_by_user_id = {}
|
||||
for memory in memories:
|
||||
memories_by_user_id.setdefault(memory.user_id, []).append(memory)
|
||||
|
||||
users_result = await Users.get_users()
|
||||
users = users_result.get('users', []) if users_result else []
|
||||
total_memories = 0
|
||||
|
||||
for memory_user in users:
|
||||
total_memories += await reindex_memory_vectors_for_user(
|
||||
request,
|
||||
memory_user.id,
|
||||
memories=memories_by_user_id.get(memory_user.id, []),
|
||||
user=memory_user,
|
||||
)
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MEMORY_RESET,
|
||||
actor=user,
|
||||
subject_id='all',
|
||||
subject_type='user',
|
||||
data={'count': total_memories, 'user_count': len(users), 'reindex': True},
|
||||
)
|
||||
return {'status': True, 'total_users': len(users), 'total_memories': total_memories}
|
||||
|
||||
|
||||
@router.post('/reset', response_model=bool)
|
||||
async def reset_memory_from_vector_db(
|
||||
request: Request,
|
||||
|
|
@ -403,32 +494,7 @@ async def reset_memory_from_vector_db(
|
|||
"""
|
||||
await check_memories_permission(user)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
|
||||
|
||||
memories = await Memories.get_memories_by_user_id(user.id)
|
||||
|
||||
# Generate vectors in parallel
|
||||
vectors = await asyncio.gather(
|
||||
*[
|
||||
request.app.state.EMBEDDING_FUNCTION(
|
||||
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
|
||||
)
|
||||
for memory in memories
|
||||
]
|
||||
)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
items=[
|
||||
{
|
||||
'id': memory.id,
|
||||
'text': memory_vector_text(memory.content, memory.path),
|
||||
'vector': vectors[idx],
|
||||
'metadata': _memory_metadata(memory),
|
||||
}
|
||||
for idx, memory in enumerate(memories)
|
||||
],
|
||||
)
|
||||
count = await reindex_memory_vectors_for_user(request, user.id, user=user)
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -436,7 +502,7 @@ async def reset_memory_from_vector_db(
|
|||
actor=user,
|
||||
subject_id=user.id,
|
||||
subject_type='user',
|
||||
data={'count': len(memories)},
|
||||
data={'count': count, 'reindex': True},
|
||||
)
|
||||
return True
|
||||
|
||||
|
|
@ -512,9 +578,10 @@ async def update_memory_by_id(
|
|||
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
|
||||
)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
items=[
|
||||
await upsert_memory_vectors_or_reindex(
|
||||
request,
|
||||
user,
|
||||
[
|
||||
{
|
||||
'id': memory.id,
|
||||
'text': memory_vector_text(memory.content, memory.path),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
from typing import Optional
|
||||
|
|
@ -20,8 +19,8 @@ from fastapi import (
|
|||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
|
|
@ -41,6 +40,7 @@ from open_webui.utils.access_control import filter_allowed_access_grants, has_pe
|
|||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from open_webui.utils.models import get_all_models
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -262,6 +262,15 @@ async def create_new_model(
|
|||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not is_valid_model_id(form_data.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.MODEL_ID_TOO_LONG,
|
||||
)
|
||||
if form_data.base_model_id == form_data.id:
|
||||
# Should never be stored: a model cannot be based on itself.
|
||||
form_data.base_model_id = None
|
||||
|
||||
model = await Models.get_model_by_id(form_data.id, db=db)
|
||||
if model:
|
||||
raise HTTPException(
|
||||
|
|
@ -269,43 +278,58 @@ async def create_new_model(
|
|||
detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
|
||||
)
|
||||
|
||||
if not is_valid_model_id(form_data.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.MODEL_ID_TOO_LONG,
|
||||
)
|
||||
|
||||
else:
|
||||
await _verify_knowledge_file_access(
|
||||
getattr(form_data.meta, 'knowledge', None) if form_data.meta else None,
|
||||
user,
|
||||
db,
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_models',
|
||||
)
|
||||
|
||||
model = await Models.insert_new_model(form_data, user.id, db=db)
|
||||
if model:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_CREATED,
|
||||
actor=user,
|
||||
subject_id=model.id,
|
||||
data={'name': model.name},
|
||||
)
|
||||
return model
|
||||
else:
|
||||
if user.role != 'admin':
|
||||
if not form_data.base_model_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not request.app.state.MODELS:
|
||||
await get_all_models(request, user=user)
|
||||
for base_model in request.app.state.MODELS.values():
|
||||
base_model_id = base_model.get('id')
|
||||
if base_model.get('preset') or not base_model_id:
|
||||
continue
|
||||
|
||||
if form_data.id == base_model_id or (
|
||||
base_model.get('owned_by') == 'ollama' and form_data.id == base_model_id.split(':', 1)[0]
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
|
||||
)
|
||||
|
||||
await _verify_knowledge_file_access(
|
||||
getattr(form_data.meta, 'knowledge', None) if form_data.meta else None,
|
||||
user,
|
||||
db,
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_models',
|
||||
)
|
||||
|
||||
model = await Models.insert_new_model(form_data, user.id, db=db)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_CREATED,
|
||||
actor=user,
|
||||
subject_id=model.id,
|
||||
data={'name': model.name},
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
############################
|
||||
# ExportModels
|
||||
|
|
@ -332,7 +356,7 @@ async def export_models(
|
|||
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return await Models.get_models(db=db)
|
||||
else:
|
||||
return await Models.get_models_by_user_id(user.id, db=db)
|
||||
return await Models.get_models(writable_by_user_id=user.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -391,12 +415,16 @@ async def import_models(
|
|||
else:
|
||||
writable_model_ids = set(existing_model_ids)
|
||||
|
||||
base_model_ids = None
|
||||
imported_ids = []
|
||||
for model_data in data:
|
||||
model_id = model_data.get('id')
|
||||
|
||||
if model_id and is_valid_model_id(model_id):
|
||||
imported_ids.append(model_id)
|
||||
if model_data.get('base_model_id') == model_id:
|
||||
# Should never be stored: heal bad exports/API payloads.
|
||||
model_data['base_model_id'] = None
|
||||
|
||||
# Defense-in-depth: skip models referencing inaccessible files
|
||||
try:
|
||||
await _verify_knowledge_file_access(
|
||||
|
|
@ -427,6 +455,18 @@ async def import_models(
|
|||
)
|
||||
continue
|
||||
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and existing_model.base_model_id
|
||||
and not model_data.get('base_model_id', existing_model.base_model_id)
|
||||
):
|
||||
log.warning(
|
||||
'import_models: user %s skipped model %s (cannot clear base model)',
|
||||
user.id,
|
||||
model_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Update existing model
|
||||
model_data['meta'] = {
|
||||
**existing_model.meta.model_dump(),
|
||||
|
|
@ -452,6 +492,37 @@ async def import_models(
|
|||
model_data['meta'] = model_data.get('meta', {})
|
||||
model_data['params'] = model_data.get('params', {})
|
||||
new_model = ModelForm(**model_data)
|
||||
|
||||
if user.role != 'admin':
|
||||
if not new_model.base_model_id:
|
||||
log.warning(
|
||||
'import_models: user %s skipped model %s (no base model set)',
|
||||
user.id,
|
||||
model_id,
|
||||
)
|
||||
continue
|
||||
|
||||
if base_model_ids is None:
|
||||
base_model_ids = set()
|
||||
if not request.app.state.MODELS:
|
||||
await get_all_models(request, user=user)
|
||||
for base_model in request.app.state.MODELS.values():
|
||||
base_model_id = base_model.get('id')
|
||||
if base_model.get('preset') or not base_model_id:
|
||||
continue
|
||||
|
||||
base_model_ids.add(base_model_id)
|
||||
if base_model.get('owned_by') == 'ollama':
|
||||
base_model_ids.add(base_model_id.split(':', 1)[0])
|
||||
|
||||
if model_id in base_model_ids:
|
||||
log.warning(
|
||||
'import_models: user %s skipped model %s (id belongs to a base model)',
|
||||
user.id,
|
||||
model_id,
|
||||
)
|
||||
continue
|
||||
|
||||
new_model.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
|
|
@ -460,6 +531,8 @@ async def import_models(
|
|||
'sharing.public_models',
|
||||
)
|
||||
await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db)
|
||||
|
||||
imported_ids.append(model_id)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_IMPORTED,
|
||||
|
|
@ -609,6 +682,9 @@ async def get_model_profile_image(
|
|||
|
||||
# only serve known-safe raster types inline; reject SVG/unknown (can run script on our origin)
|
||||
if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
|
||||
# LICENSE covers this Open WebUI fallback logo.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
return RedirectResponse(
|
||||
url='/static/favicon.png',
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
|
|
@ -636,6 +712,9 @@ async def get_model_profile_image(
|
|||
status_code=status.HTTP_302_FOUND,
|
||||
)
|
||||
|
||||
# LICENSE covers this Open WebUI fallback logo.
|
||||
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
# https://docs.openwebui.com/license.
|
||||
return RedirectResponse(
|
||||
url='/static/favicon.png',
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
|
|
@ -737,6 +816,15 @@ async def update_model_by_id(
|
|||
|
||||
if 'base_model_id' not in form_data.model_fields_set:
|
||||
form_data.base_model_id = model.base_model_id
|
||||
if form_data.base_model_id == form_data.id:
|
||||
# Should never be stored: a model cannot be based on itself.
|
||||
form_data.base_model_id = None
|
||||
|
||||
if user.role != 'admin' and model.base_model_id and not form_data.base_model_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if 'profile_image_url' not in form_data.meta.model_fields_set:
|
||||
form_data.meta.profile_image_url = model.meta.profile_image_url
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue