mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
Compare commits
No commits in common. "main" and "v0.9.1" have entirely different histories.
946 changed files with 109697 additions and 223465 deletions
23
.env.example
23
.env.example
|
|
@ -13,31 +13,10 @@ OPENAI_API_KEY=''
|
|||
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
|
||||
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
|
||||
|
||||
# For production you should set this to match the proxy configuration (127.0.0.1)
|
||||
FORWARDED_ALLOW_IPS='*'
|
||||
|
||||
# DO NOT TRACK
|
||||
SCARF_NO_ANALYTICS=true
|
||||
DO_NOT_TRACK=true
|
||||
ANONYMIZED_TELEMETRY=false
|
||||
|
||||
# Valkey Vector Store (requires VECTOR_DB=valkey)
|
||||
# VALKEY_URL='valkey://localhost:6379'
|
||||
# VALKEY_COLLECTION_PREFIX='open_webui'
|
||||
# VALKEY_INDEX_TYPE='HNSW'
|
||||
# VALKEY_DISTANCE_METRIC='COSINE'
|
||||
# VALKEY_HNSW_M='16'
|
||||
# VALKEY_HNSW_EF_CONSTRUCTION='200'
|
||||
# VALKEY_HNSW_EF_RUNTIME='10'
|
||||
ANONYMIZED_TELEMETRY=false
|
||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1 +1 @@
|
|||
github: open-webui
|
||||
github: tjbck
|
||||
|
|
|
|||
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -2,6 +2,7 @@ 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:
|
||||
|
|
@ -34,8 +35,6 @@ 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
|
||||
|
||||
|
|
@ -56,7 +55,7 @@ body:
|
|||
id: open-webui-version
|
||||
attributes:
|
||||
label: Open WebUI Version
|
||||
description: Specify the version (e.g., v0.11.0)
|
||||
description: Specify the version (e.g., v0.6.26)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -64,7 +63,7 @@ body:
|
|||
id: ollama-version
|
||||
attributes:
|
||||
label: Ollama Version (if applicable)
|
||||
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
|
||||
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
|
@ -72,7 +71,7 @@ body:
|
|||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
|
||||
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -80,7 +79,7 @@ body:
|
|||
id: browser
|
||||
attributes:
|
||||
label: Browser (if applicable)
|
||||
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
|
||||
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
|
@ -139,11 +138,11 @@ body:
|
|||
|
||||
placeholder: |
|
||||
Example (include every detail):
|
||||
1. Start with a clean Ubuntu 26.04 install.
|
||||
2. Install Docker v29.7.1 and start the service.
|
||||
1. Start with a clean Ubuntu 22.04 install.
|
||||
2. Install Docker v24.0.5 and start the service.
|
||||
3. Clone the Open WebUI repo (git clone ...).
|
||||
4. Use the Docker Compose file without modifications.
|
||||
5. Open browser Chrome 151.0 in incognito mode.
|
||||
5. Open browser Chrome 115.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".
|
||||
|
|
|
|||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,5 +1 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🔒 Report a Security Vulnerability
|
||||
url: https://github.com/open-webui/open-webui/security
|
||||
about: Do NOT open a public issue for security vulnerabilities, suspected vulnerabilities, or any security-related concern. Please review our Security Policy and report privately via the "Report a vulnerability" button so it can be handled as a private advisory.
|
||||
|
|
|
|||
68
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
68
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -1,78 +1,82 @@
|
|||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
description: Suggest an idea for this project
|
||||
title: 'feat: '
|
||||
labels: ['triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Before Submitting
|
||||
## Important Notes
|
||||
### Before submitting
|
||||
|
||||
Please check the **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) AND [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
|
||||
It's likely we're already tracking it! If you’re unsure, start a discussion post first.
|
||||
|
||||
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
|
||||
#### Scope
|
||||
|
||||
### Scope Guidelines
|
||||
If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
|
||||
**We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
|
||||
This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
|
||||
Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
|
||||
|
||||
If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
|
||||
|
||||
This will help us efficiently focus on improving the project.
|
||||
|
||||
### Collaborate respectfully
|
||||
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
|
||||
|
||||
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
|
||||
Remember:
|
||||
- Open WebUI is a **volunteer-driven project**
|
||||
- It's managed by a **single maintainer**
|
||||
- It's supported by contributors who also have **full-time jobs**
|
||||
|
||||
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
|
||||
|
||||
### Be Respectful
|
||||
|
||||
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
|
||||
We appreciate your time and ask that you **respect ours**.
|
||||
|
||||
### Contributing
|
||||
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
||||
|
||||
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
|
||||
|
||||
### Reproducibility
|
||||
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
|
||||
### Bug reproducibility
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
|
||||
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
attributes:
|
||||
label: Check Existing Issues
|
||||
description: Confirm you have searched for similar requests.
|
||||
description: Please confirm that you've checked for existing similar requests
|
||||
options:
|
||||
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
|
||||
- label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is 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
|
||||
attributes:
|
||||
label: Verify Feature Scope
|
||||
description: Confirm this request belongs in Issues rather than Discussions.
|
||||
description: Please confirm the feature's scope is within the described scope
|
||||
options:
|
||||
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
|
||||
- label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the Discussions.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: Is this related to a problem? Describe the pain point clearly.
|
||||
placeholder: "e.g., I'm frustrated when..."
|
||||
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
|
||||
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe what you would like to happen.
|
||||
label: Desired Solution you'd like
|
||||
description: Clearly describe what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you have considered.
|
||||
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request.
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
|
|
|
|||
15
.github/dependabot.yml
vendored
15
.github/dependabot.yml
vendored
|
|
@ -4,22 +4,17 @@ updates:
|
|||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: '/backend'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every week
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
target-branch: 'dev'
|
||||
|
|
|
|||
98
.github/pull_request_template.md
vendored
98
.github/pull_request_template.md
vendored
|
|
@ -1,114 +1,92 @@
|
|||
<!--
|
||||
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
|
||||
Do not open a PR as the first step.
|
||||
For real, reproducible bugs, start with a well-described Issue that explains the problem, why it matters, and what outcome you are looking for.
|
||||
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active Discussion.
|
||||
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
|
||||
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
|
||||
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
|
||||
We may close unsolicited PRs without review.
|
||||
Contributors with a history of successful merged PRs may be given more latitude.
|
||||
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
-->
|
||||
|
||||
# Pull Request Checklist
|
||||
|
||||
### Do not open a pull request as the first step.
|
||||
### 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
<!--
|
||||
### ⚠️ 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 and filled out the following:**
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`.
|
||||
- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion.
|
||||
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** A concise description of the changes is provided below.
|
||||
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
|
||||
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
|
||||
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
|
||||
- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
|
||||
- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended.
|
||||
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
|
||||
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
|
||||
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
|
||||
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
|
||||
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
|
||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
||||
- **build**: Build system or dependency changes
|
||||
- **ci**: CI/CD workflow changes
|
||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
||||
- **docs**: Documentation additions or updates
|
||||
- **feat**: New features or enhancements
|
||||
- **fix**: Bug fixes or corrections
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
|
||||
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
|
||||
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
|
||||
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
|
||||
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
||||
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
|
||||
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
|
||||
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
||||
- **build**: Changes that affect the build system or external dependencies
|
||||
- **ci**: Changes to our continuous integration processes or workflows
|
||||
- **chore**: Refactor, cleanup, or other non-functional code changes
|
||||
- **docs**: Documentation update or addition
|
||||
- **feat**: Introduces a new feature or enhancement to the codebase
|
||||
- **fix**: Bug fix or error correction
|
||||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
- **perf**: Performance improvement
|
||||
- **refactor**: Code restructuring for better maintainability, readability, or scalability
|
||||
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
|
||||
|
||||
# Changelog Entry
|
||||
|
||||
### Description
|
||||
|
||||
- [Describe the changes, including motivation and impact]
|
||||
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
|
||||
|
||||
### Added
|
||||
|
||||
- [New features, functionalities, or additions]
|
||||
- [List any new features, functionalities, or additions]
|
||||
|
||||
### Changed
|
||||
|
||||
- [Changes, updates, refactorings, or optimizations]
|
||||
- [List any changes, updates, refactorings, or optimizations]
|
||||
|
||||
### Deprecated
|
||||
|
||||
- [Deprecated functionality or features]
|
||||
- [List any deprecated functionality or features that have been removed]
|
||||
|
||||
### Removed
|
||||
|
||||
- [Removed features, files, or functionalities]
|
||||
- [List any removed features, files, or functionalities]
|
||||
|
||||
### Fixed
|
||||
|
||||
- [Bug fixes or corrections]
|
||||
- [List any fixes, corrections, or bug fixes]
|
||||
|
||||
### Security
|
||||
|
||||
- [Security-related changes or vulnerability fixes]
|
||||
- [List any new or updated security-related changes, including vulnerability fixes]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
|
||||
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
|
||||
|
||||
---
|
||||
|
||||
### Additional Information
|
||||
|
||||
- [Any additional context, notes, or references to related issues/commits]
|
||||
- [Insert any additional context, notes, or explanations for the changes]
|
||||
- [Reference any related issues, commits, or other relevant information]
|
||||
|
||||
### Screenshots or Videos
|
||||
|
||||
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
|
||||
- [Attach any relevant screenshots or videos demonstrating the changes]
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
|
|
|
|||
43
.github/workflows/backend.yaml
vendored
43
.github/workflows/backend.yaml
vendored
|
|
@ -1,43 +0,0 @@
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Backend CI — Python formatting checks via Ruff
|
||||
# Runs on pushes and PRs to main/dev when backend files change
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Python CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
|
||||
|
||||
concurrency:
|
||||
group: backend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── Ruff format check across supported Python versions ───────────────────
|
||||
format-check:
|
||||
name: Ruff Format (${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12']
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install formatter
|
||||
run: pip install "ruff>=0.15.5"
|
||||
|
||||
- name: Verify formatting
|
||||
run: ruff format --check . --exclude .venv --exclude venv
|
||||
|
||||
- name: Detect logic errors
|
||||
run: ruff check --select=F --ignore=F401,F403,F405,F541,F811,F841 --output-format=github .
|
||||
61
.github/workflows/build-release.yml
vendored
Normal file
61
.github/workflows/build-release.yml
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # or whatever branch you want to use
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check for changes in package.json
|
||||
run: |
|
||||
git diff --cached --diff-filter=d package.json || {
|
||||
echo "No changes to package.json"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Get version number from package.json
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
echo "::set-output name=version::$VERSION"
|
||||
|
||||
- name: Extract latest CHANGELOG entry
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.version }}"
|
||||
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "v${{ steps.get_version.outputs.version }}" \
|
||||
--title "v${{ steps.get_version.outputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md
|
||||
|
||||
- name: Upload package to GitHub release
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: package
|
||||
path: |
|
||||
.
|
||||
!.git
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build workflow
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'docker-build.yaml',
|
||||
ref: 'v${{ steps.get_version.outputs.version }}',
|
||||
})
|
||||
917
.github/workflows/docker-build.yaml
vendored
Normal file
917
.github/workflows/docker-build.yaml
vendored
Normal file
|
|
@ -0,0 +1,917 @@
|
|||
name: Create and publish Docker images with specific build args
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-main-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (latest)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-main-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-cuda-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Delete huge unnecessary tools folder
|
||||
run: rm -rf /opt/hostedtoolcache
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (cuda tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-cuda-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (cuda)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_CUDA=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-cuda-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-cuda126-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Delete huge unnecessary tools folder
|
||||
run: rm -rf /opt/hostedtoolcache
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (cuda126 tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda126,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-cuda126-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (cuda126)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_CUDA=true
|
||||
USE_CUDA_VER=cu126
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-cuda126-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-ollama-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (ollama tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-ollama,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-ollama-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (ollama)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_OLLAMA=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-ollama-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-slim-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (slim tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-slim,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-slim-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (slim)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_SLIM=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-slim-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-main-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-main-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-main-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-cuda-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-cuda-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-cuda-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-cuda126-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-cuda126-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-cuda126-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda126,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-ollama-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-ollama-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-ollama-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default ollama tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-ollama,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-slim-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-slim-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-slim-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default slim tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-slim,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
|
||||
copy-to-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: main
|
||||
suffix: ""
|
||||
- variant: cuda
|
||||
suffix: "-cuda"
|
||||
- variant: cuda126
|
||||
suffix: "-cuda126"
|
||||
- variant: ollama
|
||||
suffix: "-ollama"
|
||||
- variant: slim
|
||||
suffix: "-slim"
|
||||
steps:
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine source and destination tags
|
||||
id: tags
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="openwebui/open-webui"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# For version tags: copy version tag and major.minor tag
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# For main branch
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
echo "tags=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# e.g. latest-cuda -> also tag as just "cuda"
|
||||
VARIANT_NAME="${SUFFIX#-}"
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Copy images from GHCR to Docker Hub
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
# Determine the source tag on GHCR
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
SOURCE_TAG="${VERSION}${SUFFIX}"
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
SOURCE_TAG="latest"
|
||||
else
|
||||
SOURCE_TAG="latest${SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
|
||||
|
||||
echo "Copying from ${SOURCE} to Docker Hub..."
|
||||
|
||||
# Copy each destination tag
|
||||
while IFS= read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
407
.github/workflows/docker.yaml
vendored
407
.github/workflows/docker.yaml
vendored
|
|
@ -1,407 +0,0 @@
|
|||
name: Create and publish Docker images with specific build args
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- v*
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- arch: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
variant:
|
||||
- name: main
|
||||
suffix: ""
|
||||
build_args: ""
|
||||
free_disk: false
|
||||
- name: cuda
|
||||
suffix: "-cuda"
|
||||
build_args: "USE_CUDA=true"
|
||||
free_disk: true
|
||||
- name: cuda126
|
||||
suffix: "-cuda126"
|
||||
build_args: |
|
||||
USE_CUDA=true
|
||||
USE_CUDA_VER=cu126
|
||||
free_disk: true
|
||||
- name: ollama
|
||||
suffix: "-ollama"
|
||||
build_args: "USE_OLLAMA=true"
|
||||
free_disk: false
|
||||
- name: slim
|
||||
suffix: "-slim"
|
||||
build_args: "USE_SLIM=true"
|
||||
free_disk: false
|
||||
|
||||
steps:
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
platform=${{ matrix.platform.arch }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Free disk space
|
||||
if: matrix.variant.free_disk
|
||||
run: rm -rf /opt/hostedtoolcache
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Prepare CI Dockerfile
|
||||
run: |
|
||||
awk '
|
||||
/^FROM --platform=\$BUILDPLATFORM node:/ {
|
||||
print
|
||||
print "ENV NODE_OPTIONS=\"--max-old-space-size=12288\""
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' Dockerfile > "${RUNNER_TEMP}/Dockerfile"
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-${{ matrix.variant.name }}-${{ matrix.platform.arch }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: ${{ runner.temp }}/Dockerfile
|
||||
push: true
|
||||
platforms: ${{ matrix.platform.arch }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
${{ matrix.variant.build_args }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.variant.name }}-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- name: main
|
||||
suffix: ""
|
||||
- name: cuda
|
||||
suffix: "-cuda"
|
||||
- name: cuda126
|
||||
suffix: "-cuda126"
|
||||
- name: ollama
|
||||
suffix: "-ollama"
|
||||
- name: slim
|
||||
suffix: "-slim"
|
||||
|
||||
steps:
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Download digests
|
||||
id: download
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-${{ matrix.variant.name }}-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check digests
|
||||
id: check
|
||||
run: |
|
||||
count=$(find /tmp/digests -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "digest_count=$count" >> $GITHUB_OUTPUT
|
||||
if [ "$count" -lt 2 ]; then
|
||||
echo "::warning::${{ matrix.variant.name }}: found $count digest(s), need 2 (one per arch). Skipping merge."
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
notify-helm-charts:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [merge]
|
||||
if: ${{ !cancelled() && needs.merge.result == 'success' && (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
steps:
|
||||
- name: Create Helm charts app token
|
||||
id: helm-app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.HELM_CHARTS_APP_ID }}
|
||||
private-key: ${{ secrets.HELM_CHARTS_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: helm-charts
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Verify published Open WebUI image
|
||||
id: image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
ref_name="${GITHUB_REF_NAME}"
|
||||
|
||||
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
||||
image_tag="dev"
|
||||
else
|
||||
image_tag="${ref_name#v}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect "${image_name}:${image_tag}"
|
||||
echo "tag=${image_tag}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Dispatch Helm chart automation
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.helm-app-token.outputs.token }}
|
||||
script: |
|
||||
const isDev = context.ref === 'refs/heads/dev';
|
||||
const eventType = isDev
|
||||
? 'open-webui-dev-image-published'
|
||||
: 'open-webui-release-published';
|
||||
const refName = context.ref.replace('refs/heads/', '').replace('refs/tags/', '');
|
||||
const appVersion = refName.startsWith('v') ? refName.slice(1) : refName;
|
||||
const payload = {
|
||||
image_tag: isDev ? 'dev' : appVersion,
|
||||
source_ref: context.ref,
|
||||
source_sha: context.sha,
|
||||
source_run_id: String(context.runId),
|
||||
source_repository: context.repo.repo,
|
||||
};
|
||||
|
||||
if (!isDev) {
|
||||
payload.app_version = appVersion;
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner: context.repo.owner,
|
||||
repo: 'helm-charts',
|
||||
event_type: eventType,
|
||||
client_payload: payload,
|
||||
});
|
||||
|
||||
copy-to-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
needs: [merge]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: main
|
||||
suffix: ""
|
||||
- variant: cuda
|
||||
suffix: "-cuda"
|
||||
- variant: cuda126
|
||||
suffix: "-cuda126"
|
||||
- variant: ollama
|
||||
suffix: "-ollama"
|
||||
- variant: slim
|
||||
suffix: "-slim"
|
||||
|
||||
steps:
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine source and destination tags
|
||||
id: tags
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="openwebui/open-webui"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
echo "tags=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
VARIANT_NAME="${SUFFIX#-}"
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Copy images from GHCR to Docker Hub
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
SOURCE_TAG="${VERSION}${SUFFIX}"
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
SOURCE_TAG="latest"
|
||||
else
|
||||
SOURCE_TAG="latest${SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
|
||||
|
||||
echo "Copying from ${SOURCE} to Docker Hub..."
|
||||
|
||||
while IFS= read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
46
.github/workflows/format-backend.yaml
vendored
Normal file
46
.github/workflows/format-backend.yaml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: Python CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Format Backend'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- 3.11.x
|
||||
- 3.12.x
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '${{ matrix.python-version }}'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "ruff>=0.15.5"
|
||||
|
||||
- name: Ruff format check
|
||||
run: ruff format --check . --exclude .venv --exclude venv
|
||||
65
.github/workflows/format-build-frontend.yaml
vendored
Normal file
65
.github/workflows/format-build-frontend.yaml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: Frontend Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Format & Build Frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install --force
|
||||
|
||||
- name: Format Frontend
|
||||
run: npm run format
|
||||
|
||||
- name: Run i18next
|
||||
run: npm run i18n:parse
|
||||
|
||||
- name: Check for Changes After Format
|
||||
run: git diff --exit-code
|
||||
|
||||
- name: Build Frontend
|
||||
run: npm run build
|
||||
|
||||
test-frontend:
|
||||
name: 'Frontend Unit Tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci --force
|
||||
|
||||
- name: Run vitest
|
||||
run: npm run test:frontend
|
||||
65
.github/workflows/frontend.yaml
vendored
65
.github/workflows/frontend.yaml
vendored
|
|
@ -1,65 +0,0 @@
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Frontend CI — Lint, format check, build, and unit tests
|
||||
# Runs on pushes and PRs to main/dev, skipping backend-only changes
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Frontend Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
|
||||
|
||||
concurrency:
|
||||
group: frontend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── Format, i18n, and production build ────────────────────────────────────
|
||||
format-and-build:
|
||||
name: Format & Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --force
|
||||
|
||||
- name: Verify code formatting
|
||||
run: npm run format
|
||||
|
||||
- name: Verify i18n strings
|
||||
run: npm run i18n:parse
|
||||
|
||||
- name: Ensure working tree is clean
|
||||
run: git diff --exit-code
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
# ── Vitest unit tests ────────────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: npm ci --force
|
||||
|
||||
- name: Execute test suite
|
||||
run: npm run test:frontend
|
||||
255
.github/workflows/integration-test.disabled
vendored
Normal file
255
.github/workflows/integration-test.disabled
vendored
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
name: Integration Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
cypress-run:
|
||||
name: Run Cypress Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Maximize build space
|
||||
uses: AdityaGarg8/remove-unwanted-software@v4.1
|
||||
with:
|
||||
remove-android: 'true'
|
||||
remove-haskell: 'true'
|
||||
remove-codeql: 'true'
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and run Compose Stack
|
||||
run: |
|
||||
docker compose \
|
||||
--file docker-compose.yaml \
|
||||
--file docker-compose.api.yaml \
|
||||
--file docker-compose.a1111-test.yaml \
|
||||
up --detach --build
|
||||
|
||||
- name: Delete Docker build cache
|
||||
run: |
|
||||
docker builder prune --all --force
|
||||
|
||||
- name: Wait for Ollama to be up
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
until curl --output /dev/null --silent --fail http://localhost:11434; do
|
||||
printf '.'
|
||||
sleep 1
|
||||
done
|
||||
echo "Service is up!"
|
||||
|
||||
- name: Preload Ollama model
|
||||
run: |
|
||||
docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v6
|
||||
env:
|
||||
LIBGL_ALWAYS_SOFTWARE: 1
|
||||
with:
|
||||
browser: chrome
|
||||
wait-on: 'http://localhost:3000'
|
||||
config: baseUrl=http://localhost:3000
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload Cypress videos
|
||||
with:
|
||||
name: cypress-videos
|
||||
path: cypress/videos
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Extract Compose logs
|
||||
if: always()
|
||||
run: |
|
||||
docker compose logs > compose-logs.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload Compose logs
|
||||
with:
|
||||
name: compose-logs
|
||||
path: compose-logs.txt
|
||||
if-no-files-found: ignore
|
||||
|
||||
# pytest:
|
||||
# name: Run Backend Tests
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
|
||||
# - name: Set up Python
|
||||
# uses: actions/setup-python@v5
|
||||
# with:
|
||||
# python-version: ${{ matrix.python-version }}
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: |
|
||||
# python -m pip install --upgrade pip
|
||||
# pip install -r backend/requirements.txt
|
||||
|
||||
# - name: pytest run
|
||||
# run: |
|
||||
# ls -al
|
||||
# cd backend
|
||||
# PYTHONPATH=. pytest . -o log_cli=true -o log_cli_level=INFO
|
||||
|
||||
migration_test:
|
||||
name: Run Migration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
# mysql:
|
||||
# image: mysql
|
||||
# env:
|
||||
# MYSQL_ROOT_PASSWORD: mysql
|
||||
# MYSQL_DATABASE: mysql
|
||||
# options: >-
|
||||
# --health-cmd "mysqladmin ping -h localhost"
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
# ports:
|
||||
# - 3306:3306
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
uses: yezz123/setup-uv@v4
|
||||
with:
|
||||
uv-venv: venv
|
||||
|
||||
- name: Activate virtualenv
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
echo PATH=$PATH >> $GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -r backend/requirements.txt
|
||||
|
||||
- name: Test backend with SQLite
|
||||
id: sqlite
|
||||
env:
|
||||
WEBUI_SECRET_KEY: secret-key
|
||||
GLOBAL_LOG_LEVEL: debug
|
||||
run: |
|
||||
cd backend
|
||||
uvicorn open_webui.main:app --port "8080" --forwarded-allow-ips '*' &
|
||||
UVICORN_PID=$!
|
||||
# Wait up to 40 seconds for the server to start
|
||||
for i in {1..40}; do
|
||||
curl -s http://localhost:8080/api/config > /dev/null && break
|
||||
sleep 1
|
||||
if [ $i -eq 40 ]; then
|
||||
echo "Server failed to start"
|
||||
kill -9 $UVICORN_PID
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Check that the server is still running after 5 seconds
|
||||
sleep 5
|
||||
if ! kill -0 $UVICORN_PID; then
|
||||
echo "Server has stopped"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test backend with Postgres
|
||||
if: success() || steps.sqlite.conclusion == 'failure'
|
||||
env:
|
||||
WEBUI_SECRET_KEY: secret-key
|
||||
GLOBAL_LOG_LEVEL: debug
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DATABASE_POOL_SIZE: 10
|
||||
DATABASE_POOL_MAX_OVERFLOW: 10
|
||||
DATABASE_POOL_TIMEOUT: 30
|
||||
run: |
|
||||
cd backend
|
||||
uvicorn open_webui.main:app --port "8081" --forwarded-allow-ips '*' &
|
||||
UVICORN_PID=$!
|
||||
# Wait up to 20 seconds for the server to start
|
||||
for i in {1..20}; do
|
||||
curl -s http://localhost:8081/api/config > /dev/null && break
|
||||
sleep 1
|
||||
if [ $i -eq 20 ]; then
|
||||
echo "Server failed to start"
|
||||
kill -9 $UVICORN_PID
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Check that the server is still running after 5 seconds
|
||||
sleep 5
|
||||
if ! kill -0 $UVICORN_PID; then
|
||||
echo "Server has stopped"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that service will reconnect to postgres when connection will be closed
|
||||
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
|
||||
if [[ "$status_code" -ne 200 ]] ; then
|
||||
echo "Server has failed before postgres reconnect check"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Terminating all connections to postgres..."
|
||||
python -c "import os, psycopg2 as pg2; \
|
||||
conn = pg2.connect(dsn=os.environ['DATABASE_URL'].replace('+pool', '')); \
|
||||
cur = conn.cursor(); \
|
||||
cur.execute('SELECT pg_terminate_backend(psa.pid) FROM pg_stat_activity psa WHERE datname = current_database() AND pid <> pg_backend_pid();')"
|
||||
|
||||
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
|
||||
if [[ "$status_code" -ne 200 ]] ; then
|
||||
echo "Server has not reconnected to postgres after connection was closed: returned status $status_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# - name: Test backend with MySQL
|
||||
# if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure'
|
||||
# env:
|
||||
# WEBUI_SECRET_KEY: secret-key
|
||||
# GLOBAL_LOG_LEVEL: debug
|
||||
# DATABASE_URL: mysql://root:mysql@localhost:3306/mysql
|
||||
# run: |
|
||||
# cd backend
|
||||
# uvicorn open_webui.main:app --port "8083" --forwarded-allow-ips '*' &
|
||||
# UVICORN_PID=$!
|
||||
# # Wait up to 20 seconds for the server to start
|
||||
# for i in {1..20}; do
|
||||
# curl -s http://localhost:8083/api/config > /dev/null && break
|
||||
# sleep 1
|
||||
# if [ $i -eq 20 ]; then
|
||||
# echo "Server failed to start"
|
||||
# kill -9 $UVICORN_PID
|
||||
# exit 1
|
||||
# fi
|
||||
# done
|
||||
# # Check that the server is still running after 5 seconds
|
||||
# sleep 5
|
||||
# if ! kill -0 $UVICORN_PID; then
|
||||
# echo "Server has stopped"
|
||||
# exit 1
|
||||
# fi
|
||||
139
.github/workflows/issue-label.yaml
vendored
139
.github/workflows/issue-label.yaml
vendored
|
|
@ -1,139 +0,0 @@
|
|||
name: Issue Labeler
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label-bug-reports:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add "bug" label to unlabeled bug reports
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
|
||||
// Web-form submissions already carry the label from the issue template
|
||||
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 ?? '';
|
||||
|
||||
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
|
||||
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) {
|
||||
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 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']
|
||||
});
|
||||
74
.github/workflows/release.yml
vendored
74
.github/workflows/release.yml
vendored
|
|
@ -1,74 +0,0 @@
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Release — Create GitHub release from CHANGELOG, trigger Docker builds
|
||||
# Runs on pushes to main when package.json version changes
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Create release and trigger downstream workflows ──────────────────────
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Abort if package.json unchanged
|
||||
run: |
|
||||
git diff --cached --diff-filter=d package.json || {
|
||||
echo "package.json not modified — skipping release"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Read version
|
||||
id: pkg
|
||||
run: echo "version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract release notes from CHANGELOG
|
||||
run: |
|
||||
VER="${{ steps.pkg.outputs.version }}"
|
||||
awk "/^## \[${VER}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" \
|
||||
CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "v${{ steps.pkg.outputs.version }}" &>/dev/null; then
|
||||
echo "Release v${{ steps.pkg.outputs.version }} already exists — skipping creation"
|
||||
else
|
||||
gh release create "v${{ steps.pkg.outputs.version }}" \
|
||||
--title "v${{ steps.pkg.outputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md
|
||||
fi
|
||||
|
||||
- name: Archive source
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-archive
|
||||
path: |
|
||||
.
|
||||
!.git
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'docker.yaml',
|
||||
ref: 'v${{ steps.pkg.outputs.version }}',
|
||||
})
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -310,4 +310,3 @@ dist
|
|||
cypress/videos
|
||||
cypress/screenshots
|
||||
.vscode/settings.json
|
||||
.cptr
|
||||
|
|
|
|||
1186
CHANGELOG.md
1186
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -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,7 +32,6 @@ 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
|
||||
|
||||
|
|
|
|||
28
Dockerfile
28
Dockerfile
|
|
@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
|
|||
RUN npm run build
|
||||
|
||||
######## WebUI backend ########
|
||||
FROM python:3.11-slim-bookworm AS base
|
||||
FROM python:3.11.14-slim-bookworm AS base
|
||||
|
||||
# Use args
|
||||
ARG USE_CUDA
|
||||
|
|
@ -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 curl jq ca-certificates \
|
||||
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||
libmariadb-dev \
|
||||
python3-dev \
|
||||
ffmpeg libsm6 libxext6 zstd \
|
||||
|
|
@ -135,9 +135,6 @@ RUN apt-get update && \
|
|||
# install python dependencies
|
||||
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||
|
||||
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
RUN set -e; \
|
||||
pip3 install --no-cache-dir uv; \
|
||||
if [ "$USE_CUDA" = "true" ]; then \
|
||||
|
|
@ -149,7 +146,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', download_dir='/usr/local/share/nltk_data')"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
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,18 +155,12 @@ 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', download_dir='/usr/local/share/nltk_data')"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
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 && \
|
||||
|
|
@ -190,17 +181,6 @@ COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
|
|||
# copy backend files
|
||||
COPY --chown=$UID:$GID ./backend .
|
||||
|
||||
# The backend rewrites its bundled static assets (favicons, splash, manifest,
|
||||
# loader.js, ...) under open_webui/static at startup. Make that directory
|
||||
# writable by an arbitrary UID -- which under OpenShift's restricted SCC is
|
||||
# always a member of GID 0 -- so those writes don't fail with EACCES and crash
|
||||
# the boot log with "[Errno 13] Permission denied". `chmod -R g=u` mirrors the
|
||||
# owner bits onto the group (the Red Hat arbitrary-UID idiom). This is applied
|
||||
# unconditionally because it targets a directory the app writes on every start;
|
||||
# the broader, opt-in USE_PERMISSION_HARDENING below covers the rest of /app.
|
||||
RUN chgrp -R 0 /app/backend/open_webui/static && \
|
||||
chmod -R g=u /app/backend/open_webui/static
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1
|
||||
|
|
|
|||
80
README.md
80
README.md
|
|
@ -8,7 +8,7 @@
|
|||

|
||||

|
||||
[](https://discord.gg/5rJgQTnV4s)
|
||||
[](https://github.com/sponsors/open-webui)
|
||||
[](https://github.com/sponsors/tjbck)
|
||||
|
||||

|
||||
|
||||
|
|
@ -27,82 +27,58 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
|||
|
||||
## Key Features of Open WebUI ⭐
|
||||
|
||||
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
|
||||
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
|
||||
|
||||
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
|
||||
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
|
||||
|
||||
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
|
||||
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
|
||||
|
||||
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
|
||||
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
|
||||
|
||||
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
|
||||
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
|
||||
|
||||
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
|
||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||
|
||||
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
|
||||
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
|
||||
|
||||
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
|
||||
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
|
||||
|
||||
- ✅ **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
|
||||
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
|
||||
|
||||
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
|
||||
|
||||
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
|
||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||
|
||||
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
|
||||
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
|
||||
|
||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
|
||||
|
||||
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
|
||||
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
|
||||
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
|
||||
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
|
||||
|
||||
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
|
||||
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
|
||||
|
||||
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
|
||||
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
|
||||
|
||||
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
|
||||
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
|
||||
|
||||
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
|
||||
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
|
||||
|
||||
- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
|
||||
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
|
||||
|
||||
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
|
||||
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
|
||||
|
||||
- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
|
||||
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
|
||||
|
||||
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
|
||||
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
|
||||
|
||||
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
|
||||
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
|
||||
|
||||
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
|
||||
|
||||
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
|
||||
|
||||
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
|
||||
|
||||
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
|
||||
|
||||
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
|
||||
|
||||
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
|
||||
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
|
||||
|
||||
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
|
||||
|
||||
## The Open WebUI Ecosystem 🌐
|
||||
|
||||
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
|
||||
|
||||
- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
|
||||
|
||||
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
|
||||
|
||||
- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine.
|
||||
|
||||
Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details!
|
||||
|
||||
---
|
||||
|
||||
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
|
||||
|
|
@ -246,10 +222,6 @@ This project contains code under multiple licenses. The current codebase include
|
|||
If you have any questions, suggestions, or need assistance, please open an issue or join our
|
||||
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
|
||||
|
||||
## Security 🛡️
|
||||
|
||||
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#open-webui/open-webui&Date">
|
||||
|
|
|
|||
|
|
@ -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:-*}" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}" --reload
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import base64
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -11,16 +10,12 @@ import uvicorn
|
|||
app = typer.Typer()
|
||||
|
||||
KEY_FILE = Path.cwd() / '.webui_secret_key'
|
||||
DEFAULT_SECRET_KEY_LENGTH = 24
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
|
@ -41,11 +36,8 @@ def serve(
|
|||
if os.getenv('WEBUI_SECRET_KEY') is None:
|
||||
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
|
||||
if not KEY_FILE.exists():
|
||||
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
|
||||
if key_length < 1:
|
||||
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
|
||||
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
|
||||
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
|
||||
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
|
||||
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
|
||||
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
|
||||
|
||||
|
|
@ -74,12 +66,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, 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
|
||||
# asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py.
|
||||
loop = 'none' if sys.platform == 'win32' else 'auto'
|
||||
from open_webui.env import UVICORN_WORKERS # Import the workers setting
|
||||
|
||||
uvicorn.run(
|
||||
'open_webui.main:app',
|
||||
|
|
@ -87,8 +74,6 @@ def serve(
|
|||
port=port,
|
||||
forwarded_allow_ips='*',
|
||||
workers=UVICORN_WORKERS,
|
||||
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -98,15 +83,12 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,29 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
from enum import Enum
|
||||
|
||||
|
||||
_ERRNO_MESSAGES = {
|
||||
errno.ENAMETOOLONG: 'File name is too long.',
|
||||
errno.ENOSPC: 'The server is out of storage space.',
|
||||
errno.EDQUOT: 'Server storage quota exceeded.',
|
||||
errno.EACCES: 'Server storage is not writable.',
|
||||
errno.EPERM: 'Server storage is not writable.',
|
||||
errno.EROFS: 'Server storage is not writable.',
|
||||
}
|
||||
|
||||
|
||||
def _error_message(err='', fallback='') -> str:
|
||||
if not err:
|
||||
return 'Something went wrong :/'
|
||||
if isinstance(err, OSError) and err.errno in _ERRNO_MESSAGES:
|
||||
return f'[ERROR: {_ERRNO_MESSAGES[err.errno]}]'
|
||||
if isinstance(err, Exception):
|
||||
return f'[ERROR: {fallback}]' if fallback else 'Something went wrong :/'
|
||||
return f'[ERROR: {err}]'
|
||||
|
||||
|
||||
class MESSAGES(str, Enum):
|
||||
DEFAULT = lambda msg='': f'{msg if msg else ""}'
|
||||
MODEL_ADDED = lambda model='': f"The model '{model}' has been added successfully."
|
||||
|
|
@ -39,7 +16,7 @@ class ERROR_MESSAGES(str, Enum):
|
|||
def __str__(self) -> str:
|
||||
return super().__str__()
|
||||
|
||||
DEFAULT = _error_message
|
||||
DEFAULT = lambda err='': f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
|
||||
ENV_VAR_NOT_FOUND = 'Required environment variable not found. Terminating now.'
|
||||
CREATE_USER_ERROR = 'Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance.'
|
||||
DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.'
|
||||
|
|
@ -95,11 +72,11 @@ class ERROR_MESSAGES(str, Enum):
|
|||
|
||||
EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.'
|
||||
|
||||
DB_NOT_SQLITE = 'This feature is only available with SQLite databases.'
|
||||
DB_NOT_SQLITE = 'This feature is only available when running with SQLite databases.'
|
||||
|
||||
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
|
||||
INVALID_URL = 'Oops! The URL you provided is invalid. Please double-check and try again.'
|
||||
|
||||
WEB_SEARCH_ERROR = 'Something went wrong while searching the web.'
|
||||
WEB_SEARCH_ERROR = lambda err='': f'{err if err else "Oops! Something went wrong while searching the web."}'
|
||||
|
||||
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
|
||||
|
||||
|
|
@ -118,15 +95,9 @@ 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"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,11 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from typing import AsyncGenerator, Generator, Iterator
|
||||
import inspect
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import AsyncGenerator, Generator, Iterator
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
|
|
@ -14,36 +16,40 @@ from fastapi import (
|
|||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import (
|
||||
get_event_call,
|
||||
get_event_emitter,
|
||||
)
|
||||
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.access_control import check_model_access
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
from open_webui.utils.misc import (
|
||||
add_or_update_system_message,
|
||||
get_last_user_message,
|
||||
prepend_to_first_user_message_content,
|
||||
openai_chat_chunk_message_template,
|
||||
openai_chat_completion_message_template,
|
||||
prepend_to_first_user_message_content,
|
||||
)
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_openai,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.plugin import (
|
||||
get_function_module_from_cache,
|
||||
load_function_module_by_id,
|
||||
)
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -69,9 +75,6 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
|
|||
|
||||
|
||||
async def get_function_models(request):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
|
||||
pipe_models = []
|
||||
|
||||
|
|
@ -100,7 +103,7 @@ async def get_function_models(request):
|
|||
log.exception(e)
|
||||
sub_pipes = []
|
||||
|
||||
log.debug("get_function_models: function '%s' is a manifold of %s", pipe.id, sub_pipes)
|
||||
log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}")
|
||||
|
||||
for p in sub_pipes:
|
||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||
|
|
@ -126,10 +129,7 @@ async def get_function_models(request):
|
|||
pipe_flag = {'type': 'pipe'}
|
||||
|
||||
log.debug(
|
||||
"get_function_models: function '%s' is a single pipe { 'id': %s, 'name': %s }",
|
||||
pipe.id,
|
||||
pipe.id,
|
||||
pipe.name,
|
||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||
)
|
||||
|
||||
pipe_models.append(
|
||||
|
|
@ -150,10 +150,7 @@ async def get_function_models(request):
|
|||
return pipe_models
|
||||
|
||||
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
|
||||
if models is None:
|
||||
models = {}
|
||||
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
|
||||
async def execute_pipe(pipe, params):
|
||||
if inspect.iscoroutinefunction(pipe):
|
||||
return await pipe(**params)
|
||||
|
|
@ -173,7 +170,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: {JSONCodec.dumps(line)}'
|
||||
line = f'data: {json.dumps(line)}'
|
||||
|
||||
try:
|
||||
line = line.decode('utf-8')
|
||||
|
|
@ -184,7 +181,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: {JSONCodec.dumps(line)}\n\n'
|
||||
return f'data: {json.dumps(line)}\n\n'
|
||||
|
||||
def get_pipe_id(form_data: dict) -> str:
|
||||
pipe_id = form_data['model']
|
||||
|
|
@ -212,13 +209,6 @@ 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}
|
||||
|
||||
model_id = form_data.get('model')
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
|
||||
|
|
@ -294,8 +284,7 @@ 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)
|
||||
if not bypass_system_prompt:
|
||||
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
form_data = 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)
|
||||
|
|
@ -315,17 +304,17 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
yield data
|
||||
return
|
||||
if isinstance(res, dict):
|
||||
yield f'data: {JSONCodec.dumps(res)}\n\n'
|
||||
yield f'data: {json.dumps(res)}\n\n'
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'Error: {e}')
|
||||
yield f'data: {JSONCodec.dumps({"error": {"detail": str(e)}})}\n\n'
|
||||
yield f'data: {json.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: {JSONCodec.dumps(message)}\n\n'
|
||||
yield f'data: {json.dumps(message)}\n\n'
|
||||
|
||||
if isinstance(res, Iterator):
|
||||
for line in res:
|
||||
|
|
@ -335,10 +324,11 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
async for line in res:
|
||||
yield process_line(form_data, line)
|
||||
|
||||
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
|
||||
finish_message['choices'][0]['finish_reason'] = 'stop'
|
||||
yield f'data: {JSONCodec.dumps(finish_message)}\n\n'
|
||||
yield 'data: [DONE]'
|
||||
if isinstance(res, str) or isinstance(res, Generator):
|
||||
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 'data: [DONE]'
|
||||
|
||||
return StreamingResponse(stream_content(), media_type='text/event-stream')
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,272 +1,214 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import ssl as _stdlib_ssl
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from open_webui.internal.wrappers import register_connection
|
||||
from open_webui.env import (
|
||||
DATABASE_ENABLE_IAM_TOKEN_AUTH,
|
||||
DATABASE_ENABLE_SESSION_SHARING,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
OPEN_WEBUI_DIR,
|
||||
DATABASE_URL,
|
||||
DATABASE_SCHEMA,
|
||||
DATABASE_POOL_MAX_OVERFLOW,
|
||||
DATABASE_POOL_RECYCLE,
|
||||
DATABASE_POOL_SIZE,
|
||||
DATABASE_POOL_TIMEOUT,
|
||||
DATABASE_SCHEMA,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
DATABASE_ENABLE_SESSION_SHARING,
|
||||
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
|
||||
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
|
||||
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
|
||||
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
|
||||
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
|
||||
DATABASE_URL,
|
||||
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
|
||||
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
|
||||
from peewee_migrate import Router
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, event, types
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker, Session
|
||||
from sqlalchemy.pool import QueuePool, NullPool
|
||||
from sqlalchemy.sql.type_api import _T
|
||||
from typing_extensions import Self
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── SSL URL normalization (used by sync engine & Alembic migrations) ─
|
||||
#
|
||||
# psycopg2 (sync) needs ``sslmode=`` in the connection string (it does
|
||||
# not recognise the bare ``ssl=`` key that some ORMs emit). The helpers
|
||||
# below strip all SSL-related query params, normalise them, and
|
||||
# reattach them in the canonical libpq form.
|
||||
#
|
||||
# The **async** engine now uses psycopg (v3), which speaks libpq
|
||||
# natively, so it needs no translation at all — the DATABASE_URL is
|
||||
# passed through as-is.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pop_first(params: dict[str, list[str]], key: str) -> str | None:
|
||||
"""Pop a single-valued query param, returning ``None`` if absent."""
|
||||
values = params.pop(key, None)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _is_postgres_url(url: str) -> bool:
|
||||
"""Return True if *url* looks like a PostgreSQL connection string."""
|
||||
return bool(url) and any(url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://'))
|
||||
|
||||
|
||||
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
|
||||
def extract_ssl_mode_from_url(url: str) -> tuple[str, str | None]:
|
||||
"""Strip SSL query-string parameters from a PostgreSQL URL.
|
||||
|
||||
Returns ``(url_without_ssl, ssl_dict)`` where *ssl_dict* maps
|
||||
canonical libpq key names (``sslmode``, ``sslrootcert``, …) to
|
||||
their values. Non-PostgreSQL URLs are returned unchanged with an
|
||||
empty dict.
|
||||
asyncpg and psycopg2 use different query-string keys for SSL
|
||||
(``ssl`` vs ``sslmode``). This helper removes **both** from the
|
||||
URL so that each driver can receive the correct parameter through
|
||||
its own mechanism (query-string re-injection for psycopg2,
|
||||
``connect_args`` for asyncpg).
|
||||
|
||||
Returns
|
||||
-------
|
||||
(url_without_ssl, ssl_mode)
|
||||
*url_without_ssl* is the original URL with ``ssl`` / ``sslmode``
|
||||
query parameters removed. *ssl_mode* is the extracted mode
|
||||
string (e.g. ``'require'``), or ``None`` if neither parameter
|
||||
was present.
|
||||
|
||||
Non-PostgreSQL URLs are returned unchanged with ``ssl_mode=None``.
|
||||
"""
|
||||
if not _is_postgres_url(url):
|
||||
return url, {}
|
||||
if not url or not any(url.startswith(prefix) for prefix in ('postgresql://', 'postgresql+', 'postgres://')):
|
||||
return url, None
|
||||
|
||||
parsed = urlparse(url)
|
||||
qp = parse_qs(parsed.query, keep_blank_values=True)
|
||||
query_params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
|
||||
# Prefer sslmode (libpq canonical) over the bare ``ssl`` key.
|
||||
sslmode_val = _pop_first(qp, 'sslmode')
|
||||
ssl_val = _pop_first(qp, 'ssl')
|
||||
ssl_mode = sslmode_val or ssl_val
|
||||
# Prefer sslmode (libpq canonical) over the asyncpg-only ssl key.
|
||||
ssl_mode: str | None = None
|
||||
for key in ('sslmode', 'ssl'):
|
||||
values = query_params.pop(key, None)
|
||||
if values and ssl_mode is None:
|
||||
ssl_mode = values[0]
|
||||
|
||||
ssl_dict: dict[str, str] = {}
|
||||
if ssl_mode:
|
||||
ssl_dict['sslmode'] = ssl_mode
|
||||
for key in ('sslrootcert', 'sslcert', 'sslkey', 'sslcrl'):
|
||||
val = _pop_first(qp, key)
|
||||
if val:
|
||||
ssl_dict[key] = val
|
||||
if ssl_mode is None:
|
||||
# Nothing to strip — return the URL untouched.
|
||||
return url, None
|
||||
|
||||
if not ssl_dict:
|
||||
return url, ssl_dict
|
||||
|
||||
cleaned_query = urlencode(qp, doseq=True)
|
||||
return urlunparse(parsed._replace(query=cleaned_query)), ssl_dict
|
||||
# Rebuild the query string without the SSL keys.
|
||||
remaining_query = urlencode(query_params, doseq=True)
|
||||
url_without_ssl = urlunparse(parsed._replace(query=remaining_query))
|
||||
return url_without_ssl, ssl_mode
|
||||
|
||||
|
||||
def reattach_ssl_params_to_url(url_without_ssl: str, ssl_dict: dict[str, str]) -> str:
|
||||
"""Re-append SSL query-string parameters to a cleaned PostgreSQL URL.
|
||||
def build_asyncpg_ssl_args(ssl_mode: str | None) -> dict:
|
||||
"""Convert a libpq-style SSL mode value to asyncpg ``connect_args``.
|
||||
|
||||
Used for psycopg2/libpq consumers that expect ``sslmode`` and the
|
||||
certificate-file keys in the connection string.
|
||||
Returns a dict suitable for unpacking into
|
||||
``create_async_engine(..., connect_args=...)``.
|
||||
"""
|
||||
if not ssl_dict:
|
||||
return url_without_ssl
|
||||
if ssl_mode is None:
|
||||
return {}
|
||||
|
||||
parts = [f'{k}={v}' for k, v in ssl_dict.items() if v]
|
||||
if not parts:
|
||||
return url_without_ssl
|
||||
mode = ssl_mode.lower()
|
||||
if mode == 'disable':
|
||||
return {'connect_args': {'ssl': False}}
|
||||
if mode in ('allow', 'prefer'):
|
||||
# asyncpg has no direct equivalent — omit to let it try without.
|
||||
return {}
|
||||
if mode == 'require':
|
||||
# SSL required but no certificate verification (matches libpq).
|
||||
ctx = _stdlib_ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = _stdlib_ssl.CERT_NONE
|
||||
return {'connect_args': {'ssl': ctx}}
|
||||
if mode in ('verify-ca', 'verify-full'):
|
||||
# Full verification — use the system trust store.
|
||||
ctx = _stdlib_ssl.create_default_context()
|
||||
if mode == 'verify-ca':
|
||||
ctx.check_hostname = False
|
||||
return {'connect_args': {'ssl': ctx}}
|
||||
|
||||
sep = '&' if '?' in url_without_ssl else '?'
|
||||
return f'{url_without_ssl}{sep}{"&".join(parts)}'
|
||||
# Unknown value — pass through as-is and let asyncpg decide.
|
||||
return {'connect_args': {'ssl': ssl_mode}}
|
||||
|
||||
|
||||
# Backwards-compatible aliases for external callers.
|
||||
extract_ssl_mode_from_url = extract_ssl_params_from_url
|
||||
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
|
||||
def reattach_ssl_mode_to_url(url_without_ssl: str, ssl_mode: str | None) -> str:
|
||||
"""Re-append ``sslmode=<value>`` to a cleaned PostgreSQL URL.
|
||||
|
||||
|
||||
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 ``JSONCodec.dumps`` on write and
|
||||
deserialized with ``JSONCodec.loads`` on read.
|
||||
Used for psycopg2 / libpq consumers that expect the canonical
|
||||
``sslmode`` query-string key.
|
||||
"""
|
||||
if ssl_mode is None:
|
||||
return url_without_ssl
|
||||
separator = '&' if '?' in url_without_ssl else '?'
|
||||
return f'{url_without_ssl}{separator}sslmode={ssl_mode}'
|
||||
|
||||
impl = types.UnicodeText
|
||||
|
||||
class JSONField(types.TypeDecorator):
|
||||
impl = types.Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return JSONCodec.dumps(value) if value is not None else None
|
||||
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||
return json.dumps(value)
|
||||
|
||||
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return JSONCodec.loads(value) if value is not None else None
|
||||
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
|
||||
def copy(self, **kwargs: Any) -> Self:
|
||||
return JSONField(length=self.impl.length)
|
||||
def copy(self, **kw: Any) -> Self:
|
||||
return JSONField(self.impl.length)
|
||||
|
||||
def db_value(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
def python_value(self, value):
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
# Normalize SSL params from the URL once; the sync engine needs them
|
||||
# reattached in canonical libpq form for psycopg2.
|
||||
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
|
||||
# Workaround to handle the peewee migration
|
||||
# This is required to ensure the peewee migration is handled before the alembic migration
|
||||
def handle_peewee_migration(DATABASE_URL):
|
||||
db = None
|
||||
try:
|
||||
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`).
|
||||
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DATABASE_URL)
|
||||
normalized_url = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode)
|
||||
|
||||
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
|
||||
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
|
||||
# Replace the postgresql:// with postgres:// to handle the peewee migration
|
||||
db = register_connection(normalized_url.replace('postgresql://', 'postgres://'))
|
||||
migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations'
|
||||
router = Router(db, logger=log, migrate_dir=migrate_dir)
|
||||
router.run()
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'Failed to initialize the database connection: {e}')
|
||||
log.warning('Hint: If your database password contains special characters, you may need to URL-encode it.')
|
||||
raise
|
||||
finally:
|
||||
# Properly closing the database connection
|
||||
if db and not db.is_closed():
|
||||
db.close()
|
||||
|
||||
# Assert if db connection has been closed
|
||||
if db is not None:
|
||||
assert db.is_closed(), 'Database connection is still open.'
|
||||
|
||||
|
||||
class RDSIAMTokenAuth:
|
||||
_refresh_after = timedelta(minutes=14)
|
||||
|
||||
def __init__(self, database_url: str) -> None:
|
||||
url = make_url(database_url)
|
||||
if not url.drivername.startswith(('postgresql', 'postgres')):
|
||||
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH is only supported for PostgreSQL databases')
|
||||
if not url.host or not url.username:
|
||||
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH requires a database host and user')
|
||||
|
||||
self.host = url.host
|
||||
self.port = url.port or 5432
|
||||
self.username = url.username
|
||||
self._client = None
|
||||
self._token: str | None = None
|
||||
self._expires_at = datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is None:
|
||||
import boto3
|
||||
|
||||
self._client = boto3.client('rds')
|
||||
return self._client
|
||||
|
||||
def get_password(self) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._token and now < self._expires_at:
|
||||
return self._token
|
||||
|
||||
self._token = self.client.generate_db_auth_token(
|
||||
DBHostname=self.host,
|
||||
Port=self.port,
|
||||
DBUsername=self.username,
|
||||
)
|
||||
self._expires_at = now + self._refresh_after
|
||||
log.info('AWS RDS IAM database token refreshed; next refresh after %s', self._expires_at.isoformat())
|
||||
return self._token
|
||||
if ENABLE_DB_MIGRATIONS:
|
||||
handle_peewee_migration(DATABASE_URL)
|
||||
|
||||
|
||||
_rds_iam_token_auth = RDSIAMTokenAuth(SQLALCHEMY_DATABASE_URL) if DATABASE_ENABLE_IAM_TOKEN_AUTH else None
|
||||
# Normalize SSL params from the URL once; each engine branch re-injects
|
||||
# the driver-appropriate form.
|
||||
DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE = extract_ssl_mode_from_url(DATABASE_URL)
|
||||
|
||||
|
||||
def _set_iam_token_password(dialect, conn_rec, cargs, cparams):
|
||||
if _rds_iam_token_auth is not None:
|
||||
cparams['password'] = _rds_iam_token_auth.get_password()
|
||||
|
||||
|
||||
def enable_iam_token_auth(connectable) -> None:
|
||||
if _rds_iam_token_auth is 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)
|
||||
# For psycopg2 (sync engine), re-append sslmode=<value>.
|
||||
SQLALCHEMY_DATABASE_URL = (
|
||||
reattach_ssl_mode_to_url(DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE) if DATABASE_SSL_MODE else DATABASE_URL
|
||||
)
|
||||
|
||||
|
||||
def _make_async_url(url: str) -> str:
|
||||
"""Convert a sync database URL to its async driver equivalent.
|
||||
|
||||
The async engine uses psycopg (v3) which speaks libpq natively,
|
||||
so all standard connection-string parameters (``sslmode``,
|
||||
``options``, ``target_session_attrs``, etc.) are passed through
|
||||
without any translation.
|
||||
"""
|
||||
"""Convert a sync database URL to its async driver equivalent."""
|
||||
if url.startswith('sqlite+sqlcipher://'):
|
||||
# SQLCipher has no async driver — not supported for async
|
||||
raise ValueError(
|
||||
'sqlite+sqlcipher:// URLs are not supported with async engine. '
|
||||
'Use standard sqlite:// or postgresql:// instead.'
|
||||
)
|
||||
if url.startswith('sqlite:///') or url.startswith('sqlite://'):
|
||||
return url.replace('sqlite://', 'sqlite+aiosqlite://', 1)
|
||||
# psycopg v3 — auto-selects async mode with create_async_engine
|
||||
if url.startswith('postgresql+psycopg2://'):
|
||||
return url.replace('postgresql+psycopg2://', 'postgresql+psycopg://', 1)
|
||||
return url.replace('postgresql+psycopg2://', 'postgresql+asyncpg://', 1)
|
||||
if url.startswith('postgresql://'):
|
||||
return url.replace('postgresql://', 'postgresql+psycopg://', 1)
|
||||
return url.replace('postgresql://', 'postgresql+asyncpg://', 1)
|
||||
if url.startswith('postgres://'):
|
||||
return url.replace('postgres://', 'postgresql+psycopg://', 1)
|
||||
return url.replace('postgres://', 'postgresql+asyncpg://', 1)
|
||||
# For other dialects, return as-is and let SQLAlchemy handle it
|
||||
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)
|
||||
|
|
@ -295,7 +237,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,
|
||||
|
|
@ -307,7 +249,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
|||
echo=False,
|
||||
)
|
||||
else:
|
||||
engine = _create_engine(
|
||||
engine = create_engine(
|
||||
'sqlite://',
|
||||
creator=create_sqlcipher_connection,
|
||||
poolclass=NullPool,
|
||||
|
|
@ -317,7 +259,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."""
|
||||
|
|
@ -349,7 +291,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,
|
||||
|
|
@ -359,11 +301,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)
|
||||
|
||||
enable_iam_token_auth(engine)
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
|
||||
# Sync session — used ONLY for startup config loading (config.py runs at import time)
|
||||
|
|
@ -389,61 +329,56 @@ get_db = contextmanager(get_session)
|
|||
# ASYNC ENGINE (used for ALL runtime database operations)
|
||||
# ============================================================
|
||||
|
||||
# psycopg (v3) speaks libpq natively — the full DATABASE_URL is passed
|
||||
# through as-is. SSL params, ``options``, ``target_session_attrs``, etc.
|
||||
# all work without any stripping or translation.
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
# psycopg v3 cannot run in async mode under Windows' default
|
||||
# ProactorEventLoop — switch to SelectorEventLoop before creating
|
||||
# the async engine. This runs at import time, which is early enough
|
||||
# to cover every entry point (workers, reload, direct invocations).
|
||||
if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
# Use the SSL-stripped URL for asyncpg — SSL is injected via connect_args.
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(
|
||||
DATABASE_URL_WITHOUT_SSL if DATABASE_SSL_MODE else SQLALCHEMY_DATABASE_URL
|
||||
)
|
||||
|
||||
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||
# Generous default — async coroutines + no session sharing = high connection demand.
|
||||
# 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,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
@event.listens_for(async_engine.sync_engine, 'connect')
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||
_apply_sqlite_pragmas(dbapi_connection)
|
||||
else:
|
||||
# Inject asyncpg-compatible SSL connect_args when the user specified
|
||||
# sslmode/ssl in DATABASE_URL.
|
||||
asyncpg_ssl_args = build_asyncpg_ssl_args(DATABASE_SSL_MODE)
|
||||
|
||||
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,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
**asyncpg_ssl_args,
|
||||
)
|
||||
else:
|
||||
async_engine = _create_async_engine(
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
poolclass=NullPool,
|
||||
**asyncpg_ssl_args,
|
||||
)
|
||||
else:
|
||||
async_engine = _create_async_engine(
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
**asyncpg_ssl_args,
|
||||
)
|
||||
|
||||
enable_iam_token_auth(async_engine)
|
||||
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
|
|
@ -474,7 +409,7 @@ async def get_async_db():
|
|||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_async_db_context(db: AsyncSession | None = None):
|
||||
async def get_async_db_context(db: Optional[AsyncSession] = None):
|
||||
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
|
||||
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
|
||||
yield db
|
||||
|
|
|
|||
253
backend/open_webui/internal/migrations/001_initial_schema.py
Normal file
253
backend/open_webui/internal/migrations/001_initial_schema.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"""Peewee migrations -- 001_initial_schema.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# We perform different migrations for SQLite and other databases
|
||||
# This is because SQLite is very loose with enforcing its schema, and trying to migrate other databases like SQLite
|
||||
# will require per-database SQL queries.
|
||||
# Instead, we assume that because external DB support was added at a later date, it is safe to assume a newer base
|
||||
# schema instead of trying to migrate from an older schema.
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
migrate_sqlite(migrator, database, fake=fake)
|
||||
else:
|
||||
migrate_external(migrator, database, fake=fake)
|
||||
|
||||
|
||||
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
@migrator.create_model
|
||||
class Auth(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
email = pw.CharField(max_length=255)
|
||||
password = pw.CharField(max_length=255)
|
||||
active = pw.BooleanField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'auth'
|
||||
|
||||
@migrator.create_model
|
||||
class Chat(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.CharField()
|
||||
chat = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'chat'
|
||||
|
||||
@migrator.create_model
|
||||
class ChatIdTag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
tag_name = pw.CharField(max_length=255)
|
||||
chat_id = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'chatidtag'
|
||||
|
||||
@migrator.create_model
|
||||
class Document(pw.Model):
|
||||
id = pw.AutoField()
|
||||
collection_name = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255, unique=True)
|
||||
title = pw.CharField()
|
||||
filename = pw.CharField()
|
||||
content = pw.TextField(null=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'document'
|
||||
|
||||
@migrator.create_model
|
||||
class Modelfile(pw.Model):
|
||||
id = pw.AutoField()
|
||||
tag_name = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
modelfile = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'modelfile'
|
||||
|
||||
@migrator.create_model
|
||||
class Prompt(pw.Model):
|
||||
id = pw.AutoField()
|
||||
command = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.CharField()
|
||||
content = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'prompt'
|
||||
|
||||
@migrator.create_model
|
||||
class Tag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
data = pw.TextField(null=True)
|
||||
|
||||
class Meta:
|
||||
table_name = 'tag'
|
||||
|
||||
@migrator.create_model
|
||||
class User(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
email = pw.CharField(max_length=255)
|
||||
role = pw.CharField(max_length=255)
|
||||
profile_image_url = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'user'
|
||||
|
||||
|
||||
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
@migrator.create_model
|
||||
class Auth(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
email = pw.CharField(max_length=255)
|
||||
password = pw.TextField()
|
||||
active = pw.BooleanField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'auth'
|
||||
|
||||
@migrator.create_model
|
||||
class Chat(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.TextField()
|
||||
chat = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'chat'
|
||||
|
||||
@migrator.create_model
|
||||
class ChatIdTag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
tag_name = pw.CharField(max_length=255)
|
||||
chat_id = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'chatidtag'
|
||||
|
||||
@migrator.create_model
|
||||
class Document(pw.Model):
|
||||
id = pw.AutoField()
|
||||
collection_name = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255, unique=True)
|
||||
title = pw.TextField()
|
||||
filename = pw.TextField()
|
||||
content = pw.TextField(null=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'document'
|
||||
|
||||
@migrator.create_model
|
||||
class Modelfile(pw.Model):
|
||||
id = pw.AutoField()
|
||||
tag_name = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
modelfile = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'modelfile'
|
||||
|
||||
@migrator.create_model
|
||||
class Prompt(pw.Model):
|
||||
id = pw.AutoField()
|
||||
command = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.TextField()
|
||||
content = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'prompt'
|
||||
|
||||
@migrator.create_model
|
||||
class Tag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
data = pw.TextField(null=True)
|
||||
|
||||
class Meta:
|
||||
table_name = 'tag'
|
||||
|
||||
@migrator.create_model
|
||||
class User(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
email = pw.CharField(max_length=255)
|
||||
role = pw.CharField(max_length=255)
|
||||
profile_image_url = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = 'user'
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model('user')
|
||||
|
||||
migrator.remove_model('tag')
|
||||
|
||||
migrator.remove_model('prompt')
|
||||
|
||||
migrator.remove_model('modelfile')
|
||||
|
||||
migrator.remove_model('document')
|
||||
|
||||
migrator.remove_model('chatidtag')
|
||||
|
||||
migrator.remove_model('chat')
|
||||
|
||||
migrator.remove_model('auth')
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields('chat', share_id=pw.CharField(max_length=255, null=True, unique=True))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields('chat', 'share_id')
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields('user', api_key=pw.CharField(max_length=255, null=True, unique=True))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields('user', 'api_key')
|
||||
45
backend/open_webui/internal/migrations/004_add_archived.py
Normal file
45
backend/open_webui/internal/migrations/004_add_archived.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields('chat', archived=pw.BooleanField(default=False))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields('chat', 'archived')
|
||||
125
backend/open_webui/internal/migrations/005_add_updated_at.py
Normal file
125
backend/open_webui/internal/migrations/005_add_updated_at.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
migrate_sqlite(migrator, database, fake=fake)
|
||||
else:
|
||||
migrate_external(migrator, database, fake=fake)
|
||||
|
||||
|
||||
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Adding fields created_at and updated_at to the 'chat' table
|
||||
migrator.add_fields(
|
||||
'chat',
|
||||
created_at=pw.DateTimeField(null=True), # Allow null for transition
|
||||
updated_at=pw.DateTimeField(null=True), # Allow null for transition
|
||||
)
|
||||
|
||||
# Populate the new fields from an existing 'timestamp' field
|
||||
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
|
||||
|
||||
# Now that the data has been copied, remove the original 'timestamp' field
|
||||
migrator.remove_fields('chat', 'timestamp')
|
||||
|
||||
# Update the fields to be not null now that they are populated
|
||||
migrator.change_fields(
|
||||
'chat',
|
||||
created_at=pw.DateTimeField(null=False),
|
||||
updated_at=pw.DateTimeField(null=False),
|
||||
)
|
||||
|
||||
|
||||
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Adding fields created_at and updated_at to the 'chat' table
|
||||
migrator.add_fields(
|
||||
'chat',
|
||||
created_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
)
|
||||
|
||||
# Populate the new fields from an existing 'timestamp' field
|
||||
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
|
||||
|
||||
# Now that the data has been copied, remove the original 'timestamp' field
|
||||
migrator.remove_fields('chat', 'timestamp')
|
||||
|
||||
# Update the fields to be not null now that they are populated
|
||||
migrator.change_fields(
|
||||
'chat',
|
||||
created_at=pw.BigIntegerField(null=False),
|
||||
updated_at=pw.BigIntegerField(null=False),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
rollback_sqlite(migrator, database, fake=fake)
|
||||
else:
|
||||
rollback_external(migrator, database, fake=fake)
|
||||
|
||||
|
||||
def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Recreate the timestamp field initially allowing null values for safe transition
|
||||
migrator.add_fields('chat', timestamp=pw.DateTimeField(null=True))
|
||||
|
||||
# Copy the earliest created_at date back into the new timestamp field
|
||||
# This assumes created_at was originally a copy of timestamp
|
||||
migrator.sql('UPDATE chat SET timestamp = created_at')
|
||||
|
||||
# Remove the created_at and updated_at fields
|
||||
migrator.remove_fields('chat', 'created_at', 'updated_at')
|
||||
|
||||
# Finally, alter the timestamp field to not allow nulls if that was the original setting
|
||||
migrator.change_fields('chat', timestamp=pw.DateTimeField(null=False))
|
||||
|
||||
|
||||
def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Recreate the timestamp field initially allowing null values for safe transition
|
||||
migrator.add_fields('chat', timestamp=pw.BigIntegerField(null=True))
|
||||
|
||||
# Copy the earliest created_at date back into the new timestamp field
|
||||
# This assumes created_at was originally a copy of timestamp
|
||||
migrator.sql('UPDATE chat SET timestamp = created_at')
|
||||
|
||||
# Remove the created_at and updated_at fields
|
||||
migrator.remove_fields('chat', 'created_at', 'updated_at')
|
||||
|
||||
# Finally, alter the timestamp field to not allow nulls if that was the original setting
|
||||
migrator.change_fields('chat', timestamp=pw.BigIntegerField(null=False))
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"""Peewee migrations -- 006_migrate_timestamps_and_charfields.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Alter the tables with timestamps
|
||||
migrator.change_fields(
|
||||
'chatidtag',
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'document',
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'modelfile',
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'prompt',
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'user',
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
# Alter the tables with varchar to text where necessary
|
||||
migrator.change_fields(
|
||||
'auth',
|
||||
password=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'chat',
|
||||
title=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'document',
|
||||
title=pw.TextField(),
|
||||
filename=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'prompt',
|
||||
title=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'user',
|
||||
profile_image_url=pw.TextField(),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
# Alter the tables with timestamps
|
||||
migrator.change_fields(
|
||||
'chatidtag',
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'document',
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'modelfile',
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'prompt',
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'user',
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'auth',
|
||||
password=pw.CharField(max_length=255),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'chat',
|
||||
title=pw.CharField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'document',
|
||||
title=pw.CharField(),
|
||||
filename=pw.CharField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'prompt',
|
||||
title=pw.CharField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
'user',
|
||||
profile_image_url=pw.CharField(),
|
||||
)
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Adding fields created_at and updated_at to the 'user' table
|
||||
migrator.add_fields(
|
||||
'user',
|
||||
created_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
last_active_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
)
|
||||
|
||||
# Populate the new fields from an existing 'timestamp' field
|
||||
migrator.sql(
|
||||
'UPDATE "user" SET created_at = timestamp, updated_at = timestamp, last_active_at = timestamp WHERE timestamp IS NOT NULL'
|
||||
)
|
||||
|
||||
# Now that the data has been copied, remove the original 'timestamp' field
|
||||
migrator.remove_fields('user', 'timestamp')
|
||||
|
||||
# Update the fields to be not null now that they are populated
|
||||
migrator.change_fields(
|
||||
'user',
|
||||
created_at=pw.BigIntegerField(null=False),
|
||||
updated_at=pw.BigIntegerField(null=False),
|
||||
last_active_at=pw.BigIntegerField(null=False),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
# Recreate the timestamp field initially allowing null values for safe transition
|
||||
migrator.add_fields('user', timestamp=pw.BigIntegerField(null=True))
|
||||
|
||||
# Copy the earliest created_at date back into the new timestamp field
|
||||
# This assumes created_at was originally a copy of timestamp
|
||||
migrator.sql('UPDATE "user" SET timestamp = created_at')
|
||||
|
||||
# Remove the created_at and updated_at fields
|
||||
migrator.remove_fields('user', 'created_at', 'updated_at', 'last_active_at')
|
||||
|
||||
# Finally, alter the timestamp field to not allow nulls if that was the original setting
|
||||
migrator.change_fields('user', timestamp=pw.BigIntegerField(null=False))
|
||||
52
backend/open_webui/internal/migrations/008_add_memory.py
Normal file
52
backend/open_webui/internal/migrations/008_add_memory.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
@migrator.create_model
|
||||
class Memory(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
content = pw.TextField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = 'memory'
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model('memory')
|
||||
60
backend/open_webui/internal/migrations/009_add_models.py
Normal file
60
backend/open_webui/internal/migrations/009_add_models.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class Model(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
base_model_id = pw.TextField(null=True)
|
||||
|
||||
name = pw.TextField()
|
||||
|
||||
meta = pw.TextField()
|
||||
params = pw.TextField()
|
||||
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = 'model'
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model('model')
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
import json
|
||||
|
||||
from open_webui.utils.misc import parse_ollama_modelfile
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Fetch data from 'modelfile' table and insert into 'model' table
|
||||
migrate_modelfile_to_model(migrator, database)
|
||||
# Drop the 'modelfile' table
|
||||
migrator.remove_model('modelfile')
|
||||
|
||||
|
||||
def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database):
|
||||
ModelFile = migrator.orm['modelfile']
|
||||
Model = migrator.orm['model']
|
||||
|
||||
modelfiles = ModelFile.select()
|
||||
|
||||
for modelfile in modelfiles:
|
||||
# Extract and transform data in Python
|
||||
|
||||
modelfile.modelfile = json.loads(modelfile.modelfile)
|
||||
meta = json.dumps(
|
||||
{
|
||||
'description': modelfile.modelfile.get('desc'),
|
||||
'profile_image_url': modelfile.modelfile.get('imageUrl'),
|
||||
'ollama': {'modelfile': modelfile.modelfile.get('content')},
|
||||
'suggestion_prompts': modelfile.modelfile.get('suggestionPrompts'),
|
||||
'categories': modelfile.modelfile.get('categories'),
|
||||
'user': {**modelfile.modelfile.get('user', {}), 'community': True},
|
||||
}
|
||||
)
|
||||
|
||||
info = parse_ollama_modelfile(modelfile.modelfile.get('content'))
|
||||
|
||||
# Insert the processed data into the 'model' table
|
||||
Model.create(
|
||||
id=f'ollama-{modelfile.tag_name}',
|
||||
user_id=modelfile.user_id,
|
||||
base_model_id=info.get('base_model_id'),
|
||||
name=modelfile.modelfile.get('title'),
|
||||
meta=meta,
|
||||
params=json.dumps(info.get('params', {})),
|
||||
created_at=modelfile.timestamp,
|
||||
updated_at=modelfile.timestamp,
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
recreate_modelfile_table(migrator, database)
|
||||
move_data_back_to_modelfile(migrator, database)
|
||||
migrator.remove_model('model')
|
||||
|
||||
|
||||
def recreate_modelfile_table(migrator: Migrator, database: pw.Database):
|
||||
query = """
|
||||
CREATE TABLE IF NOT EXISTS modelfile (
|
||||
user_id TEXT,
|
||||
tag_name TEXT,
|
||||
modelfile JSON,
|
||||
timestamp BIGINT
|
||||
)
|
||||
"""
|
||||
migrator.sql(query)
|
||||
|
||||
|
||||
def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database):
|
||||
Model = migrator.orm['model']
|
||||
Modelfile = migrator.orm['modelfile']
|
||||
|
||||
models = Model.select()
|
||||
|
||||
for model in models:
|
||||
# Extract and transform data in Python
|
||||
meta = json.loads(model.meta)
|
||||
|
||||
modelfile_data = {
|
||||
'title': model.name,
|
||||
'desc': meta.get('description'),
|
||||
'imageUrl': meta.get('profile_image_url'),
|
||||
'content': meta.get('ollama', {}).get('modelfile'),
|
||||
'suggestionPrompts': meta.get('suggestion_prompts'),
|
||||
'categories': meta.get('categories'),
|
||||
'user': {k: v for k, v in meta.get('user', {}).items() if k != 'community'},
|
||||
}
|
||||
|
||||
# Insert the processed data back into the 'modelfile' table
|
||||
Modelfile.create(
|
||||
user_id=model.user_id,
|
||||
tag_name=model.id,
|
||||
modelfile=modelfile_data,
|
||||
timestamp=model.created_at,
|
||||
)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Adding fields settings to the 'user' table
|
||||
migrator.add_fields('user', settings=pw.TextField(null=True))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
# Remove the settings field
|
||||
migrator.remove_fields('user', 'settings')
|
||||
60
backend/open_webui/internal/migrations/012_add_tools.py
Normal file
60
backend/open_webui/internal/migrations/012_add_tools.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class Tool(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
|
||||
name = pw.TextField()
|
||||
content = pw.TextField()
|
||||
specs = pw.TextField()
|
||||
|
||||
meta = pw.TextField()
|
||||
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = 'tool'
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model('tool')
|
||||
47
backend/open_webui/internal/migrations/013_add_user_info.py
Normal file
47
backend/open_webui/internal/migrations/013_add_user_info.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Adding fields info to the 'user' table
|
||||
migrator.add_fields('user', info=pw.TextField(null=True))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
# Remove the settings field
|
||||
migrator.remove_fields('user', 'info')
|
||||
54
backend/open_webui/internal/migrations/014_add_files.py
Normal file
54
backend/open_webui/internal/migrations/014_add_files.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class File(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
filename = pw.TextField()
|
||||
meta = pw.TextField()
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = 'file'
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model('file')
|
||||
60
backend/open_webui/internal/migrations/015_add_functions.py
Normal file
60
backend/open_webui/internal/migrations/015_add_functions.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class Function(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
|
||||
name = pw.TextField()
|
||||
type = pw.TextField()
|
||||
|
||||
content = pw.TextField()
|
||||
meta = pw.TextField()
|
||||
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = 'function'
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model('function')
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields('tool', valves=pw.TextField(null=True))
|
||||
migrator.add_fields('function', valves=pw.TextField(null=True))
|
||||
migrator.add_fields('function', is_active=pw.BooleanField(default=False))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields('tool', 'valves')
|
||||
migrator.remove_fields('function', 'valves')
|
||||
migrator.remove_fields('function', 'is_active')
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"""Peewee migrations -- 017_add_user_oauth_sub.py.
|
||||
Some examples (model - class or model name)::
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields(
|
||||
'user',
|
||||
oauth_sub=pw.TextField(null=True, unique=True),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields('user', 'oauth_sub')
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"""Peewee migrations -- 017_add_user_oauth_sub.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields(
|
||||
'function',
|
||||
is_global=pw.BooleanField(default=False),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields('function', 'is_global')
|
||||
84
backend/open_webui/internal/wrappers.py
Normal file
84
backend/open_webui/internal/wrappers.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
|
||||
from peewee import *
|
||||
from peewee import InterfaceError as PeeWeeInterfaceError
|
||||
from peewee import PostgresqlDatabase
|
||||
from playhouse.db_url import connect, parse
|
||||
from playhouse.shortcuts import ReconnectMixin
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
db_state_default = {'closed': None, 'conn': None, 'ctx': None, 'transactions': None}
|
||||
db_state = ContextVar('db_state', default=db_state_default.copy())
|
||||
|
||||
|
||||
class PeeweeConnectionState(object):
|
||||
def __init__(self, **kwargs):
|
||||
super().__setattr__('_state', db_state)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
self._state.get()[name] = value
|
||||
|
||||
def __getattr__(self, name):
|
||||
value = self._state.get()[name]
|
||||
return value
|
||||
|
||||
|
||||
class CustomReconnectMixin(ReconnectMixin):
|
||||
reconnect_errors = (
|
||||
# psycopg2
|
||||
(OperationalError, 'termin'),
|
||||
(InterfaceError, 'closed'),
|
||||
# peewee
|
||||
(PeeWeeInterfaceError, 'closed'),
|
||||
)
|
||||
|
||||
|
||||
class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
|
||||
pass
|
||||
|
||||
|
||||
def register_connection(db_url):
|
||||
# Check if using SQLCipher protocol
|
||||
if db_url.startswith('sqlite+sqlcipher://'):
|
||||
database_password = os.environ.get('DATABASE_PASSWORD')
|
||||
if not database_password or database_password.strip() == '':
|
||||
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
||||
from playhouse.sqlcipher_ext import SqlCipherDatabase
|
||||
|
||||
# Parse the database path from SQLCipher URL
|
||||
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
|
||||
db_path = db_url.replace('sqlite+sqlcipher://', '')
|
||||
|
||||
# Use Peewee's native SqlCipherDatabase with encryption
|
||||
db = SqlCipherDatabase(db_path, passphrase=database_password)
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info('Connected to encrypted SQLite database using SQLCipher')
|
||||
|
||||
else:
|
||||
# Standard database connection (existing logic)
|
||||
db = connect(db_url, unquote_user=True, unquote_password=True)
|
||||
if isinstance(db, PostgresqlDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info('Connected to PostgreSQL database')
|
||||
|
||||
# Get the connection details
|
||||
connection = parse(db_url, unquote_user=True, unquote_password=True)
|
||||
|
||||
# Use our custom database class that supports reconnection
|
||||
db = ReconnectingPostgresqlDatabase(**connection)
|
||||
db.connect(reuse_if_open=True)
|
||||
elif isinstance(db, SqliteDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info('Connected to SQLite database')
|
||||
else:
|
||||
raise ValueError('Unsupported database connection')
|
||||
return db
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,87 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Alembic environment configuration runner.
|
||||
# Coordinates database migrations in both offline and online execution modes.
|
||||
import logging.config
|
||||
import logging
|
||||
import alembic.context
|
||||
from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
|
||||
from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from open_webui.models.auths import Auth
|
||||
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
||||
from open_webui.models.chat_messages import ChatMessage # noqa: F401
|
||||
from open_webui.models.chats import Chat # noqa: F401
|
||||
from sqlalchemy import create_engine, engine_from_config, pool
|
||||
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
|
||||
from open_webui.internal.db import extract_ssl_mode_from_url, reattach_ssl_mode_to_url
|
||||
from sqlalchemy import engine_from_config, pool, create_engine
|
||||
|
||||
alembic_config = alembic.context.config
|
||||
if alembic_config.config_file_name:
|
||||
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
# Re-apply JSON formatter after fileConfig replaces handlers.
|
||||
if LOG_FORMAT == 'json':
|
||||
from open_webui.env import JSONFormatter
|
||||
|
||||
for log_handler in logging.root.handlers:
|
||||
log_handler.setFormatter(JSONFormatter())
|
||||
migration_metadata = Auth.metadata
|
||||
target_db_url = DATABASE_URL
|
||||
base_url, ssl_query_params = extract_ssl_params_from_url(target_db_url)
|
||||
if ssl_query_params:
|
||||
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
|
||||
if target_db_url:
|
||||
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
|
||||
for handler in logging.root.handlers:
|
||||
handler.setFormatter(JSONFormatter())
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Auth.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
DB_URL = DATABASE_URL
|
||||
|
||||
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2, not asyncpg).
|
||||
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DB_URL)
|
||||
DB_URL = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode) if ssl_mode else DB_URL
|
||||
|
||||
if DB_URL:
|
||||
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
|
||||
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
|
||||
alembic.context.configure(
|
||||
url=db_connection_url,
|
||||
target_metadata=migration_metadata,
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
with alembic.context.begin_transaction():
|
||||
alembic.context.run_migrations()
|
||||
|
||||
|
||||
def _get_engine_connectable():
|
||||
"""Build the database engine based on target URL and authentication credentials."""
|
||||
if target_db_url and target_db_url.startswith('sqlite+sqlcipher://'):
|
||||
if not DATABASE_PASSWORD or not DATABASE_PASSWORD.strip():
|
||||
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
||||
raw_db_path = target_db_url.replace('sqlite+sqlcipher://', '')
|
||||
if raw_db_path.startswith('/'):
|
||||
raw_db_path = raw_db_path[1:]
|
||||
|
||||
def _sqlite_cipher_creator():
|
||||
import sqlcipher3
|
||||
|
||||
cipher_conn = sqlcipher3.connect(raw_db_path, check_same_thread=False)
|
||||
cipher_conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
|
||||
return cipher_conn
|
||||
|
||||
return create_engine('sqlite://', creator=_sqlite_cipher_creator, echo=False)
|
||||
return engine_from_config(
|
||||
alembic_config.get_section(alembic_config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Execute migrations against a live database connection."""
|
||||
live_connectable = _get_engine_connectable()
|
||||
enable_iam_token_auth(live_connectable)
|
||||
with live_connectable.connect() as live_connection:
|
||||
alembic.context.configure(
|
||||
connection=live_connection,
|
||||
target_metadata=migration_metadata,
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# Handle SQLCipher URLs
|
||||
if DB_URL and DB_URL.startswith('sqlite+sqlcipher://'):
|
||||
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == '':
|
||||
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
||||
|
||||
# Extract database path from SQLCipher URL
|
||||
db_path = DB_URL.replace('sqlite+sqlcipher://', '')
|
||||
if db_path.startswith('/'):
|
||||
db_path = db_path[1:] # Remove leading slash for relative paths
|
||||
|
||||
# Create a custom creator function that uses sqlcipher3
|
||||
def create_sqlcipher_connection():
|
||||
import sqlcipher3
|
||||
|
||||
conn = sqlcipher3.connect(db_path, check_same_thread=False)
|
||||
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
|
||||
return conn
|
||||
|
||||
connectable = create_engine(
|
||||
'sqlite://', # Dummy URL since we're using creator
|
||||
creator=create_sqlcipher_connection,
|
||||
echo=False,
|
||||
)
|
||||
with alembic.context.begin_transaction():
|
||||
alembic.context.run_migrations()
|
||||
else:
|
||||
# Standard database connection (existing logic)
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
# Alembic execution entrypoint branch
|
||||
if alembic.context.is_offline_mode():
|
||||
run_migrations_offline() # run in offline mode
|
||||
if not alembic.context.is_offline_mode():
|
||||
run_migrations_online() # run in online mode
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""Alembic migration utilities."""
|
||||
|
||||
from alembic import op # noqa: E402 — alembic runtime context
|
||||
from sqlalchemy import inspect # metadata inspection
|
||||
from alembic import op
|
||||
from sqlalchemy import Inspector
|
||||
|
||||
|
||||
# --- database helper functions ---
|
||||
def get_existing_tables() -> set[str]:
|
||||
"""Return table names already present in the database."""
|
||||
conn = op.get_bind()
|
||||
return set(inspect(conn).get_table_names())
|
||||
def get_existing_tables():
|
||||
con = op.get_bind()
|
||||
inspector = Inspector.from_engine(con)
|
||||
tables = set(inspector.get_table_names())
|
||||
return tables
|
||||
|
||||
|
||||
def get_revision_id() -> str:
|
||||
"""Generate a short random revision identifier."""
|
||||
def get_revision_id():
|
||||
import uuid
|
||||
|
||||
return uuid.uuid4().hex[:12]
|
||||
return str(uuid.uuid4()).replace('-', '')[:12]
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ Create Date: 2025-08-13 03:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '018012973d35'
|
||||
down_revision = 'd31026856c01'
|
||||
|
|
@ -16,34 +16,18 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
def _idx_exists(table, idx_name):
|
||||
return any(i['name'] == idx_name for i in inspector.get_indexes(table))
|
||||
|
||||
# Chat table indexes
|
||||
if not _idx_exists('chat', 'folder_id_idx'):
|
||||
op.create_index('folder_id_idx', 'chat', ['folder_id'])
|
||||
if not _idx_exists('chat', 'user_id_pinned_idx'):
|
||||
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
|
||||
if not _idx_exists('chat', 'user_id_archived_idx'):
|
||||
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
|
||||
if not _idx_exists('chat', 'updated_at_user_id_idx'):
|
||||
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
|
||||
if not _idx_exists('chat', 'folder_id_user_id_idx'):
|
||||
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
|
||||
op.create_index('folder_id_idx', 'chat', ['folder_id'])
|
||||
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
|
||||
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
|
||||
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
|
||||
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
|
||||
|
||||
# Tag table index
|
||||
if not _idx_exists('tag', 'user_id_idx'):
|
||||
op.create_index('user_id_idx', 'tag', ['user_id'])
|
||||
op.create_index('user_id_idx', 'tag', ['user_id'])
|
||||
|
||||
# Function table index (only if is_global column exists — added by a later migration)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
func_cols = {c['name'] for c in inspector.get_columns('function')}
|
||||
if 'is_global' in func_cols and not _idx_exists('function', 'is_global_idx'):
|
||||
op.create_index('is_global_idx', 'function', ['is_global'])
|
||||
# Function table index
|
||||
op.create_index('is_global_idx', 'function', ['is_global'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update, column
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.sql import column, select, table, update
|
||||
|
||||
import json
|
||||
|
||||
revision = '1af9b942657b'
|
||||
down_revision = '242a2047eae0'
|
||||
|
|
@ -93,11 +93,8 @@ def upgrade():
|
|||
conn.execute(update_stmt)
|
||||
|
||||
# Add columns `pinned` and `meta` to 'chat'
|
||||
chat_columns = {c['name'] for c in inspector.get_columns('chat')}
|
||||
if 'pinned' not in chat_columns:
|
||||
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
|
||||
if 'meta' not in chat_columns:
|
||||
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
|
||||
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
|
||||
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
|
||||
|
||||
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
|
||||
chat = table(
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
"""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')
|
||||
|
|
@ -6,11 +6,11 @@ Create Date: 2024-10-09 21:02:35.241684
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import select, table, update
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update
|
||||
|
||||
import json
|
||||
|
||||
revision = '242a2047eae0'
|
||||
down_revision = '6a39f3d8e55c'
|
||||
|
|
@ -47,41 +47,34 @@ def upgrade():
|
|||
# If the column is already JSON, no need to do anything
|
||||
pass
|
||||
|
||||
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
|
||||
# Re-check columns after potential rename above
|
||||
current_cols = {c['name'] for c in sa.inspect(conn).get_columns('chat')}
|
||||
if 'old_chat' in current_cols:
|
||||
chat_table = table(
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('old_chat', sa.Text()),
|
||||
sa.Column('chat', sa.JSON()),
|
||||
)
|
||||
# Step 3: Migrate data from 'old_chat' to 'chat'
|
||||
chat_table = table(
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('old_chat', sa.Text()),
|
||||
sa.Column('chat', sa.JSON()),
|
||||
)
|
||||
|
||||
# - Selecting all data from the table
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
||||
for row in results:
|
||||
try:
|
||||
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
||||
json_data = json.loads(row.old_chat)
|
||||
except json.JSONDecodeError:
|
||||
json_data = None # Handle cases where the text cannot be converted to JSON
|
||||
# - Selecting all data from the table
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
||||
for row in results:
|
||||
try:
|
||||
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
||||
json_data = json.loads(row.old_chat)
|
||||
except json.JSONDecodeError:
|
||||
json_data = None # Handle cases where the text cannot be converted to JSON
|
||||
|
||||
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
|
||||
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
|
||||
|
||||
# Step 4: Drop 'old_chat' column
|
||||
print("Dropping 'old_chat' column")
|
||||
op.drop_column('chat', 'old_chat')
|
||||
# Step 4: Drop 'old_chat' column
|
||||
print("Dropping 'old_chat' column")
|
||||
op.drop_column('chat', 'old_chat')
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
columns = {col['name'] for col in sa.inspect(conn).get_columns('chat')}
|
||||
|
||||
# Step 1: Add 'old_chat' column back as Text
|
||||
if 'old_chat' not in columns:
|
||||
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
|
||||
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
|
||||
|
||||
# Step 2: Convert 'chat' JSON data back to text and store in 'old_chat'
|
||||
chat_table = table(
|
||||
|
|
@ -91,14 +84,14 @@ def downgrade():
|
|||
sa.Column('old_chat', sa.Text()),
|
||||
)
|
||||
|
||||
if 'chat' in columns:
|
||||
results = conn.execute(select(chat_table.c.id, chat_table.c.chat))
|
||||
for row in results:
|
||||
text_data = json.dumps(row.chat) if row.chat is not None else None
|
||||
conn.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.chat))
|
||||
for row in results:
|
||||
text_data = json.dumps(row.chat) if row.chat is not None else None
|
||||
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
|
||||
|
||||
# Step 3: Remove the new 'chat' JSON column
|
||||
op.drop_column('chat', 'chat')
|
||||
# Step 3: Remove the new 'chat' JSON column
|
||||
op.drop_column('chat', 'chat')
|
||||
|
||||
# Step 4: Rename 'old_chat' back to 'chat'
|
||||
op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text())
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ Create Date: 2025-11-27 03:07:56.200231
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2f1211949ecc'
|
||||
|
|
@ -20,76 +20,63 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# New columns to be added to channel_member table
|
||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
||||
if 'status' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
|
||||
if 'is_active' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
if 'is_channel_muted' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_muted',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'is_channel_pinned' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'data' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
|
||||
if 'meta' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
if 'joined_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
|
||||
if 'left_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
|
||||
if 'last_read_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
if 'updated_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_muted',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
|
||||
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
|
||||
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
# New columns to be added to message table
|
||||
msg_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
if 'is_pinned' not in msg_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column(
|
||||
'is_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'pinned_at' not in msg_cols:
|
||||
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
|
||||
if 'pinned_by' not in msg_cols:
|
||||
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column(
|
||||
'is_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ Create Date: 2026-01-23 17:15:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '374d2f66af06'
|
||||
down_revision: Union[str, None] = 'c440947495f3'
|
||||
|
|
@ -20,167 +20,150 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# If the final state already exists (prompt has 'id' PK + prompt_history exists),
|
||||
# the migration completed successfully on a prior run — nothing to do.
|
||||
if 'prompt_history' in existing_tables and 'prompt_new' not in existing_tables:
|
||||
# prompt_history exists and prompt_new was already renamed → done
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'id' in prompt_cols and 'version_id' in prompt_cols:
|
||||
return
|
||||
# Step 1: Read existing data from OLD table (schema likely command as PK)
|
||||
# We use batch_alter previously, but we want to move to new table.
|
||||
# We need to assume the OLD structure.
|
||||
|
||||
# Step 1: Read existing data from OLD table (schema: command as PK)
|
||||
# Only read if the old-schema prompt table still exists (has 'command' but no 'version_id')
|
||||
existing_prompts = []
|
||||
if 'prompt' in existing_tables and 'prompt_new' not in existing_tables:
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'command' in prompt_cols and 'version_id' not in prompt_cols:
|
||||
old_prompt_table = sa.table(
|
||||
'prompt',
|
||||
sa.column('command', sa.Text()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('title', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('timestamp', sa.BigInteger()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
old_prompt_table = sa.table(
|
||||
'prompt',
|
||||
sa.column('command', sa.Text()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('title', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('timestamp', sa.BigInteger()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
)
|
||||
|
||||
# Check if table exists/read data
|
||||
try:
|
||||
existing_prompts = conn.execute(
|
||||
sa.select(
|
||||
old_prompt_table.c.command,
|
||||
old_prompt_table.c.user_id,
|
||||
old_prompt_table.c.title,
|
||||
old_prompt_table.c.content,
|
||||
old_prompt_table.c.timestamp,
|
||||
old_prompt_table.c.access_control,
|
||||
)
|
||||
try:
|
||||
existing_prompts = conn.execute(
|
||||
sa.select(
|
||||
old_prompt_table.c.command,
|
||||
old_prompt_table.c.user_id,
|
||||
old_prompt_table.c.title,
|
||||
old_prompt_table.c.content,
|
||||
old_prompt_table.c.timestamp,
|
||||
old_prompt_table.c.access_control,
|
||||
)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
existing_prompts = []
|
||||
).fetchall()
|
||||
except Exception:
|
||||
# Fallback if table doesn't exist (new install)
|
||||
existing_prompts = []
|
||||
|
||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY (if not already created)
|
||||
if 'prompt_new' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt_new',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('command', sa.String(), unique=True, index=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('version_id', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
|
||||
op.create_table(
|
||||
'prompt_new',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('command', sa.String(), unique=True, index=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('version_id', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 3: Create prompt_history table (if not already created)
|
||||
if 'prompt_history' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt_history',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('commit_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
# Step 3: Create prompt_history table
|
||||
op.create_table(
|
||||
'prompt_history',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('commit_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 4: Migrate data (only if we have old data to migrate)
|
||||
if existing_prompts:
|
||||
prompt_new_table = sa.table(
|
||||
'prompt_new',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('command', sa.String()),
|
||||
sa.column('user_id', sa.String()),
|
||||
sa.column('name', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('data', sa.JSON()),
|
||||
sa.column('meta', sa.JSON()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
sa.column('is_active', sa.Boolean()),
|
||||
sa.column('version_id', sa.Text()),
|
||||
sa.column('tags', sa.JSON()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
sa.column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
# Step 4: Migrate data
|
||||
prompt_new_table = sa.table(
|
||||
'prompt_new',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('command', sa.String()),
|
||||
sa.column('user_id', sa.String()),
|
||||
sa.column('name', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('data', sa.JSON()),
|
||||
sa.column('meta', sa.JSON()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
sa.column('is_active', sa.Boolean()),
|
||||
sa.column('version_id', sa.Text()),
|
||||
sa.column('tags', sa.JSON()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
sa.column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
prompt_history_table = sa.table(
|
||||
'prompt_history',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('prompt_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('snapshot', sa.JSON()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('commit_message', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
prompt_history_table = sa.table(
|
||||
'prompt_history',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('prompt_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('snapshot', sa.JSON()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('commit_message', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
for row in existing_prompts:
|
||||
command = row[0]
|
||||
user_id = row[1]
|
||||
title = row[2]
|
||||
content = row[3]
|
||||
timestamp = row[4]
|
||||
access_control = row[5]
|
||||
for row in existing_prompts:
|
||||
command = row[0]
|
||||
user_id = row[1]
|
||||
title = row[2]
|
||||
content = row[3]
|
||||
timestamp = row[4]
|
||||
access_control = row[5]
|
||||
|
||||
new_uuid = str(uuid.uuid4())
|
||||
history_uuid = str(uuid.uuid4())
|
||||
clean_command = command[1:] if command and command.startswith('/') else command
|
||||
new_uuid = str(uuid.uuid4())
|
||||
history_uuid = str(uuid.uuid4())
|
||||
clean_command = command[1:] if command and command.startswith('/') else command
|
||||
|
||||
# Insert into prompt_new
|
||||
conn.execute(
|
||||
sa.insert(prompt_new_table).values(
|
||||
id=new_uuid,
|
||||
command=clean_command,
|
||||
user_id=user_id,
|
||||
name=title,
|
||||
content=content,
|
||||
data={},
|
||||
meta={},
|
||||
access_control=access_control,
|
||||
is_active=True,
|
||||
version_id=history_uuid,
|
||||
tags=[],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
# Insert into prompt_new
|
||||
conn.execute(
|
||||
sa.insert(prompt_new_table).values(
|
||||
id=new_uuid,
|
||||
command=clean_command,
|
||||
user_id=user_id,
|
||||
name=title,
|
||||
content=content,
|
||||
data={},
|
||||
meta={},
|
||||
access_control=access_control,
|
||||
is_active=True,
|
||||
version_id=history_uuid,
|
||||
tags=[],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Create initial history entry
|
||||
conn.execute(
|
||||
sa.insert(prompt_history_table).values(
|
||||
id=history_uuid,
|
||||
prompt_id=new_uuid,
|
||||
parent_id=None,
|
||||
snapshot={
|
||||
'name': title,
|
||||
'content': content,
|
||||
'command': clean_command,
|
||||
'data': {},
|
||||
'meta': {},
|
||||
'access_control': access_control,
|
||||
},
|
||||
user_id=user_id,
|
||||
commit_message=None,
|
||||
created_at=timestamp,
|
||||
)
|
||||
# Create initial history entry
|
||||
conn.execute(
|
||||
sa.insert(prompt_history_table).values(
|
||||
id=history_uuid,
|
||||
prompt_id=new_uuid,
|
||||
parent_id=None,
|
||||
snapshot={
|
||||
'name': title,
|
||||
'content': content,
|
||||
'command': clean_command,
|
||||
'data': {},
|
||||
'meta': {},
|
||||
'access_control': access_control,
|
||||
},
|
||||
user_id=user_id,
|
||||
commit_message=None,
|
||||
created_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Step 5: Replace old table with new one (only if prompt_new exists)
|
||||
# Re-check tables after potential creation above
|
||||
inspector.clear_cache()
|
||||
current_tables = set(inspector.get_table_names())
|
||||
if 'prompt_new' in current_tables:
|
||||
if 'prompt' in current_tables:
|
||||
op.drop_table('prompt')
|
||||
op.rename_table('prompt_new', 'prompt')
|
||||
# Step 5: Replace old table with new one
|
||||
op.drop_table('prompt')
|
||||
op.rename_table('prompt_new', 'prompt')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-12-30 03:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '3781e22d8b01'
|
||||
down_revision = '7826ab40b532'
|
||||
|
|
@ -16,50 +16,38 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Add 'type' column to the 'channel' table
|
||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
||||
if 'type' not in channel_cols:
|
||||
op.add_column(
|
||||
'channel',
|
||||
sa.Column(
|
||||
'type',
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'channel',
|
||||
sa.Column(
|
||||
'type',
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Add 'parent_id' column to the 'message' table for threads
|
||||
message_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
if 'parent_id' not in message_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
if 'message_reaction' not in existing_tables:
|
||||
op.create_table(
|
||||
'message_reaction',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
|
||||
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
|
||||
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
|
||||
)
|
||||
op.create_table(
|
||||
'message_reaction',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
|
||||
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
|
||||
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
|
||||
)
|
||||
|
||||
if 'channel_member' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'id', sa.Text(), nullable=False, primary_key=True, unique=True
|
||||
), # Record ID for the membership row
|
||||
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
|
||||
)
|
||||
op.create_table(
|
||||
'channel_member',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
|
||||
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ Create Date: 2025-11-17 03:45:25.123939
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import time
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '37f288994c47'
|
||||
|
|
@ -22,13 +22,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'group_member' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
# 1. Create new table
|
||||
op.create_table(
|
||||
'group_member',
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Create Date: 2025-09-08 14:19:59.583921
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '38d63c18f30f'
|
||||
|
|
@ -19,39 +19,50 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
columns = inspector.get_columns('user')
|
||||
|
||||
# ── Create oauth_session table (idempotent) ───────────────────────
|
||||
if 'oauth_session' not in existing_tables:
|
||||
op.create_table(
|
||||
'oauth_session',
|
||||
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('user.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider', sa.Text(), nullable=False),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
pk_columns = inspector.get_pk_constraint('user')['constrained_columns']
|
||||
id_column = next((col for col in columns if col['name'] == 'id'), None)
|
||||
|
||||
# Create indexes (idempotent — no-ops when table was just created
|
||||
# with the columns above, and safe to call if indexes already exist).
|
||||
existing_indexes = (
|
||||
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
|
||||
if id_column and not id_column.get('unique', False):
|
||||
unique_constraints = inspector.get_unique_constraints('user')
|
||||
unique_columns = {tuple(u['column_names']) for u in unique_constraints}
|
||||
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
# If primary key is wrong, drop it
|
||||
if pk_columns and pk_columns != ['id']:
|
||||
batch_op.drop_constraint(inspector.get_pk_constraint('user')['name'], type_='primary')
|
||||
|
||||
# Add unique constraint if missing
|
||||
if ('id',) not in unique_columns:
|
||||
batch_op.create_unique_constraint('uq_user_id', ['id'])
|
||||
|
||||
# Re-create correct primary key
|
||||
batch_op.create_primary_key('pk_user_id', ['id'])
|
||||
|
||||
# Create oauth_session table
|
||||
op.create_table(
|
||||
'oauth_session',
|
||||
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('user.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider', sa.Text(), nullable=False),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
if 'idx_oauth_session_user_id' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
|
||||
if 'idx_oauth_session_expires_at' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
|
||||
if 'idx_oauth_session_user_provider' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
|
||||
# Create indexes for better performance
|
||||
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
|
||||
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
|
||||
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update, column
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.sql import column, select, table, update
|
||||
|
||||
import json
|
||||
|
||||
revision = '3ab32c4b8f59'
|
||||
down_revision = '1af9b942657b'
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Create Date: 2025-08-21 02:07:18.078283
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3af16a1c9fb6'
|
||||
|
|
@ -19,18 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
user_cols = {c['name'] for c in inspector.get_columns('user')}
|
||||
|
||||
if 'username' not in user_cols:
|
||||
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
|
||||
if 'bio' not in user_cols:
|
||||
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
|
||||
if 'gender' not in user_cols:
|
||||
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
|
||||
if 'date_of_birth' not in user_cols:
|
||||
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
|
||||
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
|
||||
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
"""add knowledge_directory table
|
||||
|
||||
Revision ID: 3c9b0ca343fd
|
||||
Revises: a0b1c2d3e4f5
|
||||
Create Date: 2026-05-13 21:58:40.832482
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3c9b0ca343fd'
|
||||
down_revision: Union[str, None] = 'a0b1c2d3e4f5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'knowledge_directory' not in existing_tables:
|
||||
# Create knowledge_directory table
|
||||
op.create_table(
|
||||
'knowledge_directory',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('knowledge_id', sa.Text(), nullable=False),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint(
|
||||
'knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id'])
|
||||
op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id'])
|
||||
|
||||
# Add directory_id column to knowledge_file
|
||||
kf_cols = {c['name'] for c in inspector.get_columns('knowledge_file')}
|
||||
if 'directory_id' not in kf_cols:
|
||||
with op.batch_alter_table('knowledge_file') as batch:
|
||||
batch.add_column(sa.Column('directory_id', sa.Text(), nullable=True))
|
||||
batch.create_foreign_key(
|
||||
'fk_knowledge_file_directory_id',
|
||||
'knowledge_directory',
|
||||
['directory_id'],
|
||||
['id'],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
batch.create_index('ix_knowledge_file_directory_id', ['directory_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove directory_id from knowledge_file
|
||||
with op.batch_alter_table('knowledge_file') as batch:
|
||||
batch.drop_index('ix_knowledge_file_directory_id')
|
||||
batch.drop_constraint('fk_knowledge_file_directory_id', type_='foreignkey')
|
||||
batch.drop_column('directory_id')
|
||||
|
||||
# Drop knowledge_directory table
|
||||
op.drop_index('ix_knowledge_directory_parent_id', table_name='knowledge_directory')
|
||||
op.drop_index('ix_knowledge_directory_knowledge_id', table_name='knowledge_directory')
|
||||
op.drop_table('knowledge_directory')
|
||||
|
|
@ -6,15 +6,16 @@ Create Date: 2025-12-02 06:54:19.401334
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
import open_webui.internal.db
|
||||
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3e0e00844bb0'
|
||||
|
|
@ -24,13 +25,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'knowledge_file' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
op.create_table(
|
||||
'knowledge_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
|
|
|
|||
|
|
@ -1,584 +0,0 @@
|
|||
"""reshape config to per key rows
|
||||
|
||||
Revision ID: 3ff2c63645b8
|
||||
Revises: 461111b60977
|
||||
Create Date: 2026-06-17 00:50:51.477073
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3ff2c63645b8'
|
||||
down_revision: Union[str, None] = '461111b60977'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# Maps every dot-notation blob path to its legacy env/config key name.
|
||||
# Built from the legacy persistent config declarations in config.py.
|
||||
BLOB_PATH_TO_KEY = {
|
||||
'audio.stt.allowed_extensions': 'AUDIO_STT_ALLOWED_EXTENSIONS',
|
||||
'audio.stt.azure.api_key': 'AUDIO_STT_AZURE_API_KEY',
|
||||
'audio.stt.azure.base_url': 'AUDIO_STT_AZURE_BASE_URL',
|
||||
'audio.stt.azure.locales': 'AUDIO_STT_AZURE_LOCALES',
|
||||
'audio.stt.azure.max_speakers': 'AUDIO_STT_AZURE_MAX_SPEAKERS',
|
||||
'audio.stt.azure.region': 'AUDIO_STT_AZURE_REGION',
|
||||
'audio.stt.deepgram.api_key': 'DEEPGRAM_API_KEY',
|
||||
'audio.stt.engine': 'AUDIO_STT_ENGINE',
|
||||
'audio.stt.mistral.api_base_url': 'AUDIO_STT_MISTRAL_API_BASE_URL',
|
||||
'audio.stt.mistral.api_key': 'AUDIO_STT_MISTRAL_API_KEY',
|
||||
'audio.stt.mistral.use_chat_completions': 'AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS',
|
||||
'audio.stt.model': 'AUDIO_STT_MODEL',
|
||||
'audio.stt.openai.api_base_url': 'AUDIO_STT_OPENAI_API_BASE_URL',
|
||||
'audio.stt.openai.api_key': 'AUDIO_STT_OPENAI_API_KEY',
|
||||
'audio.stt.supported_content_types': 'AUDIO_STT_SUPPORTED_CONTENT_TYPES',
|
||||
'audio.stt.whisper_model': 'WHISPER_MODEL',
|
||||
'audio.tts.api_key': 'AUDIO_TTS_API_KEY',
|
||||
'audio.tts.azure.speech_base_url': 'AUDIO_TTS_AZURE_SPEECH_BASE_URL',
|
||||
'audio.tts.azure.speech_output_format': 'AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT',
|
||||
'audio.tts.azure.speech_region': 'AUDIO_TTS_AZURE_SPEECH_REGION',
|
||||
'audio.tts.engine': 'AUDIO_TTS_ENGINE',
|
||||
'audio.tts.mistral.api_base_url': 'AUDIO_TTS_MISTRAL_API_BASE_URL',
|
||||
'audio.tts.mistral.api_key': 'AUDIO_TTS_MISTRAL_API_KEY',
|
||||
'audio.tts.model': 'AUDIO_TTS_MODEL',
|
||||
'audio.tts.openai.api_base_url': 'AUDIO_TTS_OPENAI_API_BASE_URL',
|
||||
'audio.tts.openai.api_key': 'AUDIO_TTS_OPENAI_API_KEY',
|
||||
'audio.tts.openai.params': 'AUDIO_TTS_OPENAI_PARAMS',
|
||||
'audio.tts.split_on': 'AUDIO_TTS_SPLIT_ON',
|
||||
'audio.tts.voice': 'AUDIO_TTS_VOICE',
|
||||
'auth.admin.email': 'ADMIN_EMAIL',
|
||||
'auth.admin.show': 'SHOW_ADMIN_DETAILS',
|
||||
'auth.api_key.allowed_endpoints': 'API_KEYS_ALLOWED_ENDPOINTS',
|
||||
'auth.api_key.endpoint_restrictions': 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS',
|
||||
'auth.enable_api_keys': 'ENABLE_API_KEYS',
|
||||
'auth.jwt_expiry': 'JWT_EXPIRES_IN',
|
||||
'automations.enable': 'ENABLE_AUTOMATIONS',
|
||||
'automations.max_count': 'AUTOMATION_MAX_COUNT',
|
||||
'automations.min_interval': 'AUTOMATION_MIN_INTERVAL',
|
||||
'calendar.enable': 'ENABLE_CALENDAR',
|
||||
'channels.enable': 'ENABLE_CHANNELS',
|
||||
'code_execution.enable': 'ENABLE_CODE_EXECUTION',
|
||||
'code_execution.engine': 'CODE_EXECUTION_ENGINE',
|
||||
'code_execution.jupyter.auth': 'CODE_EXECUTION_JUPYTER_AUTH',
|
||||
'code_execution.jupyter.auth_password': 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD',
|
||||
'code_execution.jupyter.auth_token': 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN',
|
||||
'code_execution.jupyter.timeout': 'CODE_EXECUTION_JUPYTER_TIMEOUT',
|
||||
'code_execution.jupyter.url': 'CODE_EXECUTION_JUPYTER_URL',
|
||||
'code_interpreter.enable': 'ENABLE_CODE_INTERPRETER',
|
||||
'code_interpreter.engine': 'CODE_INTERPRETER_ENGINE',
|
||||
'code_interpreter.jupyter.auth': 'CODE_INTERPRETER_JUPYTER_AUTH',
|
||||
'code_interpreter.jupyter.auth_password': 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD',
|
||||
'code_interpreter.jupyter.auth_token': 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN',
|
||||
'code_interpreter.jupyter.timeout': 'CODE_INTERPRETER_JUPYTER_TIMEOUT',
|
||||
'code_interpreter.jupyter.url': 'CODE_INTERPRETER_JUPYTER_URL',
|
||||
'code_interpreter.prompt_template': 'CODE_INTERPRETER_PROMPT_TEMPLATE',
|
||||
'direct.enable': 'ENABLE_DIRECT_CONNECTIONS',
|
||||
'evaluation.arena.enable': 'ENABLE_EVALUATION_ARENA_MODELS',
|
||||
'evaluation.arena.models': 'EVALUATION_ARENA_MODELS',
|
||||
'file.image_compression_height': 'FILE_IMAGE_COMPRESSION_HEIGHT',
|
||||
'file.image_compression_width': 'FILE_IMAGE_COMPRESSION_WIDTH',
|
||||
'folders.enable': 'ENABLE_FOLDERS',
|
||||
'folders.max_file_count': 'FOLDER_MAX_FILE_COUNT',
|
||||
'google_drive.api_key': 'GOOGLE_DRIVE_API_KEY',
|
||||
'google_drive.client_id': 'GOOGLE_DRIVE_CLIENT_ID',
|
||||
'google_drive.enable': 'ENABLE_GOOGLE_DRIVE_INTEGRATION',
|
||||
'image_generation.automatic1111.api_auth': 'AUTOMATIC1111_API_AUTH',
|
||||
'image_generation.automatic1111.api_params': 'AUTOMATIC1111_PARAMS',
|
||||
'image_generation.automatic1111.base_url': 'AUTOMATIC1111_BASE_URL',
|
||||
'image_generation.comfyui.api_key': 'COMFYUI_API_KEY',
|
||||
'image_generation.comfyui.base_url': 'COMFYUI_BASE_URL',
|
||||
'image_generation.comfyui.nodes': 'COMFYUI_WORKFLOW_NODES',
|
||||
'image_generation.comfyui.workflow': 'COMFYUI_WORKFLOW',
|
||||
'image_generation.enable': 'ENABLE_IMAGE_GENERATION',
|
||||
'image_generation.engine': 'IMAGE_GENERATION_ENGINE',
|
||||
'image_generation.gemini.api_base_url': 'IMAGES_GEMINI_API_BASE_URL',
|
||||
'image_generation.gemini.api_key': 'IMAGES_GEMINI_API_KEY',
|
||||
'image_generation.gemini.endpoint_method': 'IMAGES_GEMINI_ENDPOINT_METHOD',
|
||||
'image_generation.model': 'IMAGE_GENERATION_MODEL',
|
||||
'image_generation.openai.api_base_url': 'IMAGES_OPENAI_API_BASE_URL',
|
||||
'image_generation.openai.api_key': 'IMAGES_OPENAI_API_KEY',
|
||||
'image_generation.openai.api_version': 'IMAGES_OPENAI_API_VERSION',
|
||||
'image_generation.openai.params': 'IMAGES_OPENAI_API_PARAMS',
|
||||
'image_generation.prompt.enable': 'ENABLE_IMAGE_PROMPT_GENERATION',
|
||||
'image_generation.size': 'IMAGE_SIZE',
|
||||
'image_generation.steps': 'IMAGE_STEPS',
|
||||
'images.edit.comfyui.api_key': 'IMAGES_EDIT_COMFYUI_API_KEY',
|
||||
'images.edit.comfyui.base_url': 'IMAGES_EDIT_COMFYUI_BASE_URL',
|
||||
'images.edit.comfyui.nodes': 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES',
|
||||
'images.edit.comfyui.workflow': 'IMAGES_EDIT_COMFYUI_WORKFLOW',
|
||||
'images.edit.enable': 'ENABLE_IMAGE_EDIT',
|
||||
'images.edit.engine': 'IMAGE_EDIT_ENGINE',
|
||||
'images.edit.gemini.api_base_url': 'IMAGES_EDIT_GEMINI_API_BASE_URL',
|
||||
'images.edit.gemini.api_key': 'IMAGES_EDIT_GEMINI_API_KEY',
|
||||
'images.edit.model': 'IMAGE_EDIT_MODEL',
|
||||
'images.edit.openai.api_base_url': 'IMAGES_EDIT_OPENAI_API_BASE_URL',
|
||||
'images.edit.openai.api_key': 'IMAGES_EDIT_OPENAI_API_KEY',
|
||||
'images.edit.openai.api_version': 'IMAGES_EDIT_OPENAI_API_VERSION',
|
||||
'images.edit.size': 'IMAGE_EDIT_SIZE',
|
||||
'ldap.enable': 'ENABLE_LDAP',
|
||||
'ldap.group.enable_creation': 'ENABLE_LDAP_GROUP_CREATION',
|
||||
'ldap.group.enable_management': 'ENABLE_LDAP_GROUP_MANAGEMENT',
|
||||
'ldap.server.app_dn': 'LDAP_APP_DN',
|
||||
'ldap.server.app_password': 'LDAP_APP_PASSWORD',
|
||||
'ldap.server.attribute_for_groups': 'LDAP_ATTRIBUTE_FOR_GROUPS',
|
||||
'ldap.server.attribute_for_mail': 'LDAP_ATTRIBUTE_FOR_MAIL',
|
||||
'ldap.server.attribute_for_username': 'LDAP_ATTRIBUTE_FOR_USERNAME',
|
||||
'ldap.server.ca_cert_file': 'LDAP_CA_CERT_FILE',
|
||||
'ldap.server.ciphers': 'LDAP_CIPHERS',
|
||||
'ldap.server.host': 'LDAP_SERVER_HOST',
|
||||
'ldap.server.label': 'LDAP_SERVER_LABEL',
|
||||
'ldap.server.port': 'LDAP_SERVER_PORT',
|
||||
'ldap.server.search_filter': 'LDAP_SEARCH_FILTER',
|
||||
'ldap.server.use_tls': 'LDAP_USE_TLS',
|
||||
'ldap.server.users_dn': 'LDAP_SEARCH_BASE',
|
||||
'ldap.server.validate_cert': 'LDAP_VALIDATE_CERT',
|
||||
'memories.enable': 'ENABLE_MEMORIES',
|
||||
'models.base_models_cache': 'ENABLE_BASE_MODELS_CACHE',
|
||||
'models.default_metadata': 'DEFAULT_MODEL_METADATA',
|
||||
'models.default_params': 'DEFAULT_MODEL_PARAMS',
|
||||
'notes.enable': 'ENABLE_NOTES',
|
||||
# OAuth — direct paths
|
||||
'oauth.admin_roles': 'OAUTH_ADMIN_ROLES',
|
||||
'oauth.allowed_domains': 'OAUTH_ALLOWED_DOMAINS',
|
||||
'oauth.allowed_roles': 'OAUTH_ALLOWED_ROLES',
|
||||
'oauth.audience': 'OAUTH_AUDIENCE',
|
||||
'oauth.auto_redirect': 'OAUTH_AUTO_REDIRECT',
|
||||
'oauth.blocked_groups': 'OAUTH_BLOCKED_GROUPS',
|
||||
'oauth.client.timeout': 'OAUTH_CLIENT_TIMEOUT',
|
||||
'oauth.enable_group_creation': 'ENABLE_OAUTH_GROUP_CREATION',
|
||||
'oauth.enable_group_mapping': 'ENABLE_OAUTH_GROUP_MANAGEMENT',
|
||||
'oauth.enable_role_mapping': 'ENABLE_OAUTH_ROLE_MANAGEMENT',
|
||||
'oauth.enable_signup': 'ENABLE_OAUTH_SIGNUP',
|
||||
'oauth.group_default_share': 'OAUTH_GROUP_DEFAULT_SHARE',
|
||||
'oauth.merge_accounts_by_email': 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL',
|
||||
'oauth.refresh_token_include_scope': 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE',
|
||||
'oauth.roles_claim': 'OAUTH_ROLES_CLAIM',
|
||||
'oauth.update_email_on_login': 'OAUTH_UPDATE_EMAIL_ON_LOGIN',
|
||||
'oauth.update_name_on_login': 'OAUTH_UPDATE_NAME_ON_LOGIN',
|
||||
'oauth.update_picture_on_login': 'OAUTH_UPDATE_PICTURE_ON_LOGIN',
|
||||
# OAuth — generic provider paths
|
||||
'oauth.client_id': 'OAUTH_CLIENT_ID',
|
||||
'oauth.client_secret': 'OAUTH_CLIENT_SECRET',
|
||||
'oauth.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
|
||||
'oauth.email_claim': 'OAUTH_EMAIL_CLAIM',
|
||||
'oauth.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
|
||||
'oauth.group_claim': 'OAUTH_GROUP_CLAIM',
|
||||
'oauth.picture_claim': 'OAUTH_PICTURE_CLAIM',
|
||||
'oauth.provider_name': 'OAUTH_PROVIDER_NAME',
|
||||
'oauth.provider_url': 'OPENID_PROVIDER_URL',
|
||||
'oauth.redirect_uri': 'OPENID_REDIRECT_URI',
|
||||
'oauth.scopes': 'OAUTH_SCOPES',
|
||||
'oauth.sub_claim': 'OAUTH_SUB_CLAIM',
|
||||
'oauth.timeout': 'OAUTH_TIMEOUT',
|
||||
'oauth.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
|
||||
'oauth.username_claim': 'OAUTH_USERNAME_CLAIM',
|
||||
# OAuth — OIDC nested paths (flattened)
|
||||
'oauth.oidc.avatar_claim': 'OAUTH_PICTURE_CLAIM',
|
||||
'oauth.oidc.client_id': 'OAUTH_CLIENT_ID',
|
||||
'oauth.oidc.client_secret': 'OAUTH_CLIENT_SECRET',
|
||||
'oauth.oidc.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
|
||||
'oauth.oidc.email_claim': 'OAUTH_EMAIL_CLAIM',
|
||||
'oauth.oidc.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
|
||||
'oauth.oidc.group_claim': 'OAUTH_GROUP_CLAIM', # renamed from OAUTH_GROUPS_CLAIM
|
||||
'oauth.oidc.oauth_timeout': 'OAUTH_TIMEOUT',
|
||||
'oauth.oidc.provider_name': 'OAUTH_PROVIDER_NAME',
|
||||
'oauth.oidc.provider_url': 'OPENID_PROVIDER_URL',
|
||||
'oauth.oidc.redirect_uri': 'OPENID_REDIRECT_URI',
|
||||
'oauth.oidc.scopes': 'OAUTH_SCOPES',
|
||||
'oauth.oidc.sub_claim': 'OAUTH_SUB_CLAIM',
|
||||
'oauth.oidc.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
|
||||
'oauth.oidc.username_claim': 'OAUTH_USERNAME_CLAIM',
|
||||
# OAuth — provider-specific
|
||||
'oauth.feishu.client_id': 'FEISHU_CLIENT_ID',
|
||||
'oauth.feishu.client_secret': 'FEISHU_CLIENT_SECRET',
|
||||
'oauth.feishu.redirect_uri': 'FEISHU_REDIRECT_URI',
|
||||
'oauth.feishu.scope': 'FEISHU_OAUTH_SCOPE',
|
||||
'oauth.github.client_id': 'GITHUB_CLIENT_ID',
|
||||
'oauth.github.client_secret': 'GITHUB_CLIENT_SECRET',
|
||||
'oauth.github.redirect_uri': 'GITHUB_CLIENT_REDIRECT_URI',
|
||||
'oauth.github.scope': 'GITHUB_CLIENT_SCOPE',
|
||||
'oauth.google.client_id': 'GOOGLE_CLIENT_ID',
|
||||
'oauth.google.client_secret': 'GOOGLE_CLIENT_SECRET',
|
||||
'oauth.google.redirect_uri': 'GOOGLE_REDIRECT_URI',
|
||||
'oauth.google.scope': 'GOOGLE_OAUTH_SCOPE',
|
||||
'oauth.microsoft.client_id': 'MICROSOFT_CLIENT_ID',
|
||||
'oauth.microsoft.client_secret': 'MICROSOFT_CLIENT_SECRET',
|
||||
'oauth.microsoft.login_base_url': 'MICROSOFT_CLIENT_LOGIN_BASE_URL',
|
||||
'oauth.microsoft.picture_url': 'MICROSOFT_CLIENT_PICTURE_URL',
|
||||
'oauth.microsoft.redirect_uri': 'MICROSOFT_REDIRECT_URI',
|
||||
'oauth.microsoft.scope': 'MICROSOFT_OAUTH_SCOPE',
|
||||
'oauth.microsoft.tenant_id': 'MICROSOFT_CLIENT_TENANT_ID',
|
||||
# Ollama / OpenAI
|
||||
'ollama.api_configs': 'OLLAMA_API_CONFIGS',
|
||||
'ollama.base_urls': 'OLLAMA_BASE_URLS',
|
||||
'ollama.enable': 'ENABLE_OLLAMA_API',
|
||||
'onedrive.enable': 'ENABLE_ONEDRIVE_INTEGRATION',
|
||||
'onedrive.sharepoint_tenant_id': 'ONEDRIVE_SHAREPOINT_TENANT_ID',
|
||||
'onedrive.sharepoint_url': 'ONEDRIVE_SHAREPOINT_URL',
|
||||
'openai.api_base_urls': 'OPENAI_API_BASE_URLS',
|
||||
'openai.api_configs': 'OPENAI_API_CONFIGS',
|
||||
'openai.api_keys': 'OPENAI_API_KEYS',
|
||||
'openai.enable': 'ENABLE_OPENAI_API',
|
||||
# RAG
|
||||
'rag.content_extraction_engine': 'CONTENT_EXTRACTION_ENGINE',
|
||||
'rag.datalab_marker_use_llm': 'DATALAB_MARKER_USE_LLM',
|
||||
'rag.mistral_ocr_api_base_url': 'MISTRAL_OCR_API_BASE_URL',
|
||||
'rag.azure_openai.api_key': 'RAG_AZURE_OPENAI_API_KEY',
|
||||
'rag.azure_openai.api_version': 'RAG_AZURE_OPENAI_API_VERSION',
|
||||
'rag.azure_openai.base_url': 'RAG_AZURE_OPENAI_BASE_URL',
|
||||
'rag.bypass_embedding_and_retrieval': 'BYPASS_EMBEDDING_AND_RETRIEVAL',
|
||||
'rag.chunk_min_size_target': 'CHUNK_MIN_SIZE_TARGET',
|
||||
'rag.chunk_overlap': 'CHUNK_OVERLAP',
|
||||
'rag.chunk_size': 'CHUNK_SIZE',
|
||||
'rag.datalab_marker_additional_config': 'DATALAB_MARKER_ADDITIONAL_CONFIG',
|
||||
'rag.datalab_marker_api_base_url': 'DATALAB_MARKER_API_BASE_URL',
|
||||
'rag.datalab_marker_api_key': 'DATALAB_MARKER_API_KEY',
|
||||
'rag.datalab_marker_disable_image_extraction': 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION',
|
||||
'rag.datalab_marker_force_ocr': 'DATALAB_MARKER_FORCE_OCR',
|
||||
'rag.datalab_marker_format_lines': 'DATALAB_MARKER_FORMAT_LINES',
|
||||
'rag.datalab_marker_output_format': 'DATALAB_MARKER_OUTPUT_FORMAT',
|
||||
'rag.datalab_marker_paginate': 'DATALAB_MARKER_PAGINATE',
|
||||
'rag.datalab_marker_skip_cache': 'DATALAB_MARKER_SKIP_CACHE',
|
||||
'rag.datalab_marker_strip_existing_ocr': 'DATALAB_MARKER_STRIP_EXISTING_OCR',
|
||||
'rag.docling_api_key': 'DOCLING_API_KEY',
|
||||
'rag.docling_params': 'DOCLING_PARAMS',
|
||||
'rag.docling_server_url': 'DOCLING_SERVER_URL',
|
||||
'rag.document_intelligence_endpoint': 'DOCUMENT_INTELLIGENCE_ENDPOINT',
|
||||
'rag.document_intelligence_key': 'DOCUMENT_INTELLIGENCE_KEY',
|
||||
'rag.document_intelligence_model': 'DOCUMENT_INTELLIGENCE_MODEL',
|
||||
'rag.embedding_batch_size': 'RAG_EMBEDDING_BATCH_SIZE',
|
||||
'rag.embedding_concurrent_requests': 'RAG_EMBEDDING_CONCURRENT_REQUESTS',
|
||||
'rag.embedding_engine': 'RAG_EMBEDDING_ENGINE',
|
||||
'rag.embedding_model': 'RAG_EMBEDDING_MODEL',
|
||||
'rag.enable_async_embedding': 'ENABLE_ASYNC_EMBEDDING',
|
||||
'rag.enable_hybrid_search': 'ENABLE_RAG_HYBRID_SEARCH',
|
||||
'rag.enable_hybrid_search_enriched_texts': 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS',
|
||||
'rag.enable_markdown_header_text_splitter': 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER',
|
||||
'rag.external_document_loader_api_key': 'EXTERNAL_DOCUMENT_LOADER_API_KEY',
|
||||
'rag.external_document_loader_url': 'EXTERNAL_DOCUMENT_LOADER_URL',
|
||||
'rag.external_reranker_api_key': 'RAG_EXTERNAL_RERANKER_API_KEY',
|
||||
'rag.external_reranker_timeout': 'RAG_EXTERNAL_RERANKER_TIMEOUT',
|
||||
'rag.external_reranker_url': 'RAG_EXTERNAL_RERANKER_URL',
|
||||
'rag.file.allowed_extensions': 'RAG_ALLOWED_FILE_EXTENSIONS',
|
||||
'rag.file.max_count': 'RAG_FILE_MAX_COUNT',
|
||||
'rag.file.max_size': 'RAG_FILE_MAX_SIZE',
|
||||
'rag.full_context': 'RAG_FULL_CONTEXT',
|
||||
'rag.hybrid_bm25_weight': 'RAG_HYBRID_BM25_WEIGHT',
|
||||
'rag.mineru_api_key': 'MINERU_API_KEY',
|
||||
'rag.mineru_api_mode': 'MINERU_API_MODE',
|
||||
'rag.mineru_api_timeout': 'MINERU_API_TIMEOUT',
|
||||
'rag.mineru_api_url': 'MINERU_API_URL',
|
||||
'rag.mineru_file_extensions': 'MINERU_FILE_EXTENSIONS',
|
||||
'rag.mineru_params': 'MINERU_PARAMS',
|
||||
'rag.mistral_ocr_api_key': 'MISTRAL_OCR_API_KEY',
|
||||
'rag.ollama.key': 'RAG_OLLAMA_API_KEY',
|
||||
'rag.ollama.url': 'RAG_OLLAMA_BASE_URL',
|
||||
'rag.openai_api_base_url': 'RAG_OPENAI_API_BASE_URL',
|
||||
'rag.openai_api_key': 'RAG_OPENAI_API_KEY',
|
||||
'rag.paddleocr_vl_base_url': 'PADDLEOCR_VL_BASE_URL',
|
||||
'rag.paddleocr_vl_token': 'PADDLEOCR_VL_TOKEN',
|
||||
'rag.pdf_extract_images': 'PDF_EXTRACT_IMAGES',
|
||||
'rag.pdf_loader_mode': 'PDF_LOADER_MODE',
|
||||
'rag.relevance_threshold': 'RAG_RELEVANCE_THRESHOLD',
|
||||
'rag.reranking_batch_size': 'RAG_RERANKING_BATCH_SIZE',
|
||||
'rag.reranking_engine': 'RAG_RERANKING_ENGINE',
|
||||
'rag.reranking_model': 'RAG_RERANKING_MODEL',
|
||||
'rag.template': 'RAG_TEMPLATE',
|
||||
'rag.text_splitter': 'RAG_TEXT_SPLITTER',
|
||||
'rag.tika_server_url': 'TIKA_SERVER_URL',
|
||||
'rag.tiktoken_encoding_name': 'TIKTOKEN_ENCODING_NAME',
|
||||
'rag.top_k': 'RAG_TOP_K',
|
||||
'rag.top_k_reranker': 'RAG_TOP_K_RERANKER',
|
||||
# RAG — Web
|
||||
'rag.web.fetch.max_content_length': 'WEB_FETCH_MAX_CONTENT_LENGTH',
|
||||
'rag.web.loader.concurrent_requests': 'WEB_LOADER_CONCURRENT_REQUESTS',
|
||||
'rag.web.loader.engine': 'WEB_LOADER_ENGINE',
|
||||
'rag.web.loader.external_web_loader_api_key': 'EXTERNAL_WEB_LOADER_API_KEY',
|
||||
'rag.web.loader.external_web_loader_url': 'EXTERNAL_WEB_LOADER_URL',
|
||||
'rag.web.loader.firecrawl_api_key': 'FIRECRAWL_API_KEY',
|
||||
'rag.web.loader.firecrawl_api_url': 'FIRECRAWL_API_BASE_URL',
|
||||
'rag.web.loader.firecrawl_timeout': 'FIRECRAWL_TIMEOUT',
|
||||
'rag.web.loader.playwright_timeout': 'PLAYWRIGHT_TIMEOUT',
|
||||
'rag.web.loader.playwright_ws_url': 'PLAYWRIGHT_WS_URL',
|
||||
'rag.web.loader.ssl_verification': 'ENABLE_WEB_LOADER_SSL_VERIFICATION',
|
||||
'rag.web.loader.timeout': 'WEB_LOADER_TIMEOUT',
|
||||
'rag.web.search.azure_ai_search_api_key': 'AZURE_AI_SEARCH_API_KEY',
|
||||
'rag.web.search.azure_ai_search_endpoint': 'AZURE_AI_SEARCH_ENDPOINT',
|
||||
'rag.web.search.azure_ai_search_index_name': 'AZURE_AI_SEARCH_INDEX_NAME',
|
||||
'rag.web.search.bing_search_v7_endpoint': 'BING_SEARCH_V7_ENDPOINT',
|
||||
'rag.web.search.bing_search_v7_subscription_key': 'BING_SEARCH_V7_SUBSCRIPTION_KEY',
|
||||
'rag.web.search.bocha_search_api_key': 'BOCHA_SEARCH_API_KEY',
|
||||
'rag.web.search.brave_search_api_key': 'BRAVE_SEARCH_API_KEY',
|
||||
'rag.web.search.brave_search_context_tokens': 'BRAVE_SEARCH_CONTEXT_TOKENS',
|
||||
'rag.web.search.bypass_embedding_and_retrieval': 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL',
|
||||
'rag.web.search.bypass_web_loader': 'BYPASS_WEB_SEARCH_WEB_LOADER',
|
||||
'rag.web.search.concurrent_requests': 'WEB_SEARCH_CONCURRENT_REQUESTS',
|
||||
'rag.web.search.ddgs_backend': 'DDGS_BACKEND',
|
||||
'rag.web.search.domain.filter_list': 'WEB_SEARCH_DOMAIN_FILTER_LIST',
|
||||
'rag.web.search.enable': 'ENABLE_WEB_SEARCH',
|
||||
'rag.web.search.engine': 'WEB_SEARCH_ENGINE',
|
||||
'rag.web.search.exa_api_key': 'EXA_API_KEY',
|
||||
'rag.web.search.external_web_search_api_key': 'EXTERNAL_WEB_SEARCH_API_KEY',
|
||||
'rag.web.search.external_web_search_url': 'EXTERNAL_WEB_SEARCH_URL',
|
||||
'rag.web.search.google_pse_api_key': 'GOOGLE_PSE_API_KEY',
|
||||
'rag.web.search.google_pse_engine_id': 'GOOGLE_PSE_ENGINE_ID',
|
||||
'rag.web.search.jina_api_base_url': 'JINA_API_BASE_URL',
|
||||
'rag.web.search.jina_api_key': 'JINA_API_KEY',
|
||||
'rag.web.search.kagi_search_api_key': 'KAGI_SEARCH_API_KEY',
|
||||
'rag.web.search.linkup_api_key': 'LINKUP_API_KEY',
|
||||
'rag.web.search.linkup_search_params': 'LINKUP_SEARCH_PARAMS',
|
||||
'rag.web.search.mojeek_search_api_key': 'MOJEEK_SEARCH_API_KEY',
|
||||
'rag.web.search.ollama_cloud_api_key': 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY',
|
||||
'rag.web.search.perplexity_api_key': 'PERPLEXITY_API_KEY',
|
||||
'rag.web.search.perplexity_model': 'PERPLEXITY_MODEL',
|
||||
'rag.web.search.perplexity_search_api_url': 'PERPLEXITY_SEARCH_API_URL',
|
||||
'rag.web.search.perplexity_search_context_usage': 'PERPLEXITY_SEARCH_CONTEXT_USAGE',
|
||||
'rag.web.search.result_count': 'WEB_SEARCH_RESULT_COUNT',
|
||||
'rag.web.search.searchapi_api_key': 'SEARCHAPI_API_KEY',
|
||||
'rag.web.search.searchapi_engine': 'SEARCHAPI_ENGINE',
|
||||
'rag.web.search.searxng_language': 'SEARXNG_LANGUAGE',
|
||||
'rag.web.search.searxng_query_url': 'SEARXNG_QUERY_URL',
|
||||
'rag.web.search.serpapi_api_key': 'SERPAPI_API_KEY',
|
||||
'rag.web.search.serpapi_engine': 'SERPAPI_ENGINE',
|
||||
'rag.web.search.serper_api_key': 'SERPER_API_KEY',
|
||||
'rag.web.search.serply_api_key': 'SERPLY_API_KEY',
|
||||
'rag.web.search.serpstack_api_key': 'SERPSTACK_API_KEY',
|
||||
'rag.web.search.serpstack_https': 'SERPSTACK_HTTPS',
|
||||
'rag.web.search.sougou_api_sid': 'SOUGOU_API_SID',
|
||||
'rag.web.search.sougou_api_sk': 'SOUGOU_API_SK',
|
||||
'rag.web.search.tavily_api_key': 'TAVILY_API_KEY',
|
||||
'rag.web.search.tavily_extract_depth': 'TAVILY_EXTRACT_DEPTH',
|
||||
'rag.web.search.trust_env': 'WEB_SEARCH_TRUST_ENV',
|
||||
'rag.web.search.yacy_password': 'YACY_PASSWORD',
|
||||
'rag.web.search.yacy_query_url': 'YACY_QUERY_URL',
|
||||
'rag.web.search.yacy_username': 'YACY_USERNAME',
|
||||
'rag.web.search.yandex_web_search_api_key': 'YANDEX_WEB_SEARCH_API_KEY',
|
||||
'rag.web.search.yandex_web_search_config': 'YANDEX_WEB_SEARCH_CONFIG',
|
||||
'rag.web.search.yandex_web_search_url': 'YANDEX_WEB_SEARCH_URL',
|
||||
'rag.web.search.youcom_api_key': 'YOUCOM_API_KEY',
|
||||
'rag.youtube_loader_language': 'YOUTUBE_LOADER_LANGUAGE',
|
||||
'rag.youtube_loader_proxy_url': 'YOUTUBE_LOADER_PROXY_URL',
|
||||
# Tasks
|
||||
'task.autocomplete.enable': 'ENABLE_AUTOCOMPLETE_GENERATION',
|
||||
'task.autocomplete.input_max_length': 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH',
|
||||
'task.autocomplete.prompt_template': 'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE',
|
||||
'task.follow_up.enable': 'ENABLE_FOLLOW_UP_GENERATION',
|
||||
'task.follow_up.prompt_template': 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE',
|
||||
'task.image.prompt_template': 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE',
|
||||
'task.model.default': 'TASK_MODEL',
|
||||
'task.model.external': 'TASK_MODEL_EXTERNAL',
|
||||
'task.query.prompt_template': 'QUERY_GENERATION_PROMPT_TEMPLATE',
|
||||
'task.query.retrieval.enable': 'ENABLE_RETRIEVAL_QUERY_GENERATION',
|
||||
'task.query.search.enable': 'ENABLE_SEARCH_QUERY_GENERATION',
|
||||
'task.tags.enable': 'ENABLE_TAGS_GENERATION',
|
||||
'task.tags.prompt_template': 'TAGS_GENERATION_PROMPT_TEMPLATE',
|
||||
'task.title.enable': 'ENABLE_TITLE_GENERATION',
|
||||
'task.title.prompt_template': 'TITLE_GENERATION_PROMPT_TEMPLATE',
|
||||
'task.tools.prompt_template': 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE',
|
||||
'task.voice.prompt.enable': 'ENABLE_VOICE_MODE_PROMPT',
|
||||
'task.voice.prompt_template': 'VOICE_MODE_PROMPT_TEMPLATE',
|
||||
# Misc
|
||||
'terminal_server.connections': 'TERMINAL_SERVER_CONNECTIONS',
|
||||
'tool_server.connections': 'TOOL_SERVER_CONNECTIONS',
|
||||
'ui.banners': 'WEBUI_BANNERS',
|
||||
'ui.default_group_id': 'DEFAULT_GROUP_ID',
|
||||
'ui.default_locale': 'DEFAULT_LOCALE',
|
||||
'ui.default_models': 'DEFAULT_MODELS',
|
||||
'ui.default_pinned_models': 'DEFAULT_PINNED_MODELS',
|
||||
'ui.default_user_role': 'DEFAULT_USER_ROLE',
|
||||
'ui.enable_community_sharing': 'ENABLE_COMMUNITY_SHARING',
|
||||
'ui.enable_login_form': 'ENABLE_LOGIN_FORM',
|
||||
'ui.enable_message_rating': 'ENABLE_MESSAGE_RATING',
|
||||
'ui.enable_password_change_form': 'ENABLE_PASSWORD_CHANGE_FORM',
|
||||
'ui.enable_signup': 'ENABLE_SIGNUP',
|
||||
'ui.enable_user_webhooks': 'ENABLE_USER_WEBHOOKS',
|
||||
'ui.model_order_list': 'MODEL_ORDER_LIST',
|
||||
'ui.pending_user_overlay_content': 'PENDING_USER_OVERLAY_CONTENT',
|
||||
'ui.pending_user_overlay_title': 'PENDING_USER_OVERLAY_TITLE',
|
||||
'ui.prompt_suggestions': 'DEFAULT_PROMPT_SUGGESTIONS',
|
||||
'ui.watermark': 'RESPONSE_WATERMARK',
|
||||
'user.permissions': 'USER_PERMISSIONS',
|
||||
'users.enable_status': 'ENABLE_USER_STATUS',
|
||||
'webhook_url': 'WEBHOOK_URL',
|
||||
'webui.url': 'WEBUI_URL',
|
||||
}
|
||||
|
||||
|
||||
STORAGE_KEY_REWRITES = {
|
||||
'oauth.refresh_token_include_scope': 'oauth.refresh_token.include_scope',
|
||||
'rag.openai_api_base_url': 'rag.openai.api_base_url',
|
||||
'rag.openai_api_key': 'rag.openai.api_key',
|
||||
'rag.ollama.url': 'rag.ollama.base_url',
|
||||
'rag.ollama.key': 'rag.ollama.api_key',
|
||||
'oauth.oidc.avatar_claim': 'oauth.picture_claim',
|
||||
'oauth.oidc.client_id': 'oauth.client_id',
|
||||
'oauth.oidc.client_secret': 'oauth.client_secret',
|
||||
'oauth.oidc.code_challenge_method': 'oauth.code_challenge_method',
|
||||
'oauth.oidc.email_claim': 'oauth.email_claim',
|
||||
'oauth.oidc.end_session_endpoint': 'oauth.end_session_endpoint',
|
||||
'oauth.oidc.group_claim': 'oauth.group_claim',
|
||||
'oauth.oidc.oauth_timeout': 'oauth.timeout',
|
||||
'oauth.oidc.provider_name': 'oauth.provider_name',
|
||||
'oauth.oidc.provider_url': 'oauth.provider_url',
|
||||
'oauth.oidc.redirect_uri': 'oauth.redirect_uri',
|
||||
'oauth.oidc.scopes': 'oauth.scopes',
|
||||
'oauth.oidc.sub_claim': 'oauth.sub_claim',
|
||||
'oauth.oidc.token_endpoint_auth_method': 'oauth.token_endpoint_auth_method',
|
||||
'oauth.oidc.username_claim': 'oauth.username_claim',
|
||||
}
|
||||
|
||||
|
||||
LEGACY_KEY_TO_STORAGE_KEY = {
|
||||
legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path) for blob_path, legacy_key in BLOB_PATH_TO_KEY.items()
|
||||
}
|
||||
|
||||
|
||||
def _walk_blob(data: dict, prefix: str = '') -> dict:
|
||||
"""Recursively walk a nested config blob, preserving known config values.
|
||||
|
||||
Some config values are intentionally dictionaries, e.g. OPENAI_API_CONFIGS
|
||||
and OLLAMA_API_CONFIGS. Once the current path is a known config key, keep
|
||||
that value intact instead of flattening its internals into orphaned rows.
|
||||
"""
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
|
||||
if path in BLOB_PATH_TO_KEY or path in LEGACY_KEY_TO_STORAGE_KEY:
|
||||
result[path] = value
|
||||
elif isinstance(value, dict):
|
||||
result.update(_walk_blob(value, path))
|
||||
else:
|
||||
result[path] = value
|
||||
return result
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Reshape config from single-row JSON blob to per-key rows."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
table_names = set(inspector.get_table_names())
|
||||
config_columns = (
|
||||
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
|
||||
)
|
||||
has_old_config = {'id', 'data'}.issubset(config_columns)
|
||||
has_new_config = {'key', 'value'}.issubset(config_columns)
|
||||
|
||||
# Ad-hoc table reference for reading the old schema
|
||||
old_config = sa.table(
|
||||
'config',
|
||||
sa.column('id', sa.Integer),
|
||||
sa.column('data', sa.JSON),
|
||||
)
|
||||
|
||||
# 1. Read existing blob
|
||||
blob_data = {}
|
||||
if has_old_config:
|
||||
try:
|
||||
result = conn.execute(sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1))
|
||||
row = result.fetchone()
|
||||
if row and row[0]:
|
||||
raw = row[0]
|
||||
blob_data = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
pass # Table might be partially migrated or empty
|
||||
|
||||
# 2. Preserve old blob table for rollback/inspection, then create per-key table.
|
||||
if has_old_config:
|
||||
if 'config_old' in table_names:
|
||||
op.drop_table('config_old')
|
||||
op.rename_table('config', 'config_old')
|
||||
|
||||
# 3. Create new per-key table
|
||||
new_config = (
|
||||
sa.table(
|
||||
'config',
|
||||
sa.column('key', sa.Text),
|
||||
sa.column('value', sa.JSON()),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
)
|
||||
if has_new_config
|
||||
else op.create_table(
|
||||
'config',
|
||||
sa.Column('key', sa.Text(), primary_key=True),
|
||||
sa.Column('value', sa.JSON(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Flatten blob and insert per-key rows
|
||||
if blob_data:
|
||||
flat = _walk_blob(blob_data)
|
||||
|
||||
# Keep stable dot-notation paths as the database keys.
|
||||
# Known legacy env-style keys are rewritten to their dotted keys; unknown
|
||||
# keys are still copied so custom/future config is not silently lost.
|
||||
rows = {}
|
||||
for blob_path, value in flat.items():
|
||||
if blob_path in BLOB_PATH_TO_KEY:
|
||||
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
|
||||
elif blob_path in LEGACY_KEY_TO_STORAGE_KEY:
|
||||
storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path]
|
||||
else:
|
||||
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
|
||||
|
||||
if storage_key not in rows:
|
||||
rows[storage_key] = value
|
||||
|
||||
# Batch insert via SQLAlchemy table reference
|
||||
if rows:
|
||||
now = int(time.time())
|
||||
op.bulk_insert(
|
||||
new_config,
|
||||
[{'key': k, 'value': v, 'updated_at': now} for k, v in rows.items()],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore preserved old single-row config table when available."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if 'config_old' in table_names:
|
||||
if 'config' in table_names:
|
||||
op.drop_table('config')
|
||||
op.rename_table('config_old', 'config')
|
||||
return
|
||||
|
||||
config_columns = (
|
||||
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
|
||||
)
|
||||
has_per_key_config = {'key', 'value'}.issubset(config_columns)
|
||||
|
||||
blob_data = {}
|
||||
if has_per_key_config:
|
||||
config = sa.table(
|
||||
'config',
|
||||
sa.column('key', sa.Text),
|
||||
sa.column('value', sa.JSON),
|
||||
)
|
||||
for key, value in conn.execute(sa.select(config.c.key, config.c.value)):
|
||||
blob_data[key] = json.loads(value) if isinstance(value, str) else value
|
||||
op.drop_table('config')
|
||||
|
||||
if 'config' in table_names and not has_per_key_config:
|
||||
return
|
||||
|
||||
old_config = op.create_table(
|
||||
'config',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
if blob_data:
|
||||
op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}])
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"""add memory path and meta
|
||||
|
||||
Revision ID: 42e2978c7933
|
||||
Revises: 7b3f2a9c1d4e
|
||||
Create Date: 2026-06-29 05:35:50.565887
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '42e2978c7933'
|
||||
down_revision: Union[str, None] = '7b3f2a9c1d4e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('memory')}
|
||||
|
||||
if 'path' not in columns:
|
||||
op.add_column('memory', sa.Column('path', sa.Text(), nullable=True))
|
||||
if 'meta' not in columns:
|
||||
op.add_column('memory', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('memory')}
|
||||
|
||||
if 'meta' in columns:
|
||||
op.drop_column('memory', 'meta')
|
||||
if 'path' in columns:
|
||||
op.drop_column('memory', 'path')
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"""add missing primary keys to legacy peewee tables
|
||||
|
||||
Revision ID: 461111b60977
|
||||
Revises: 3c9b0ca343fd
|
||||
Create Date: 2026-05-14 04:38:14.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '461111b60977'
|
||||
down_revision: Union[str, None] = '3c9b0ca343fd'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Tables bootstrapped by the old Peewee migration layer that may have
|
||||
# UNIQUE(id) but no PRIMARY KEY constraint. Fresh Alembic installs
|
||||
# already have correct PKs from 7e5b5dc7342b_init.py.
|
||||
# 'tag' uses a composite PK since the same tag name can exist for multiple users.
|
||||
LEGACY_TABLES = {
|
||||
'auth': ['id'],
|
||||
'chat': ['id'],
|
||||
'chatidtag': ['id'],
|
||||
'document': ['id'],
|
||||
'file': ['id'],
|
||||
'function': ['id'],
|
||||
'memory': ['id'],
|
||||
'model': ['id'],
|
||||
'prompt': ['id'],
|
||||
'tag': ['id', 'user_id'],
|
||||
'tool': ['id'],
|
||||
'user': ['id'],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
for table_name, pk_columns in LEGACY_TABLES.items():
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
|
||||
pk = inspector.get_pk_constraint(table_name)
|
||||
pk_cols = pk.get('constrained_columns', [])
|
||||
|
||||
# Already has the correct PK — nothing to do
|
||||
if sorted(pk_cols) == sorted(pk_columns):
|
||||
continue
|
||||
|
||||
# Check that all PK columns exist
|
||||
columns = {c['name'] for c in inspector.get_columns(table_name)}
|
||||
if not all(c in columns for c in pk_columns):
|
||||
continue
|
||||
|
||||
print(f"Promoting UNIQUE(id) -> PRIMARY KEY({', '.join(pk_columns)}) for '{table_name}'")
|
||||
|
||||
conn.execute(sa.text(f'DROP TABLE IF EXISTS _alembic_tmp_{table_name}'))
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
# Drop existing PK if any (e.g. on wrong column)
|
||||
if pk_cols and pk.get('name'):
|
||||
batch_op.drop_constraint(pk['name'], type_='primary')
|
||||
|
||||
batch_op.create_primary_key(f'pk_{table_name}', pk_columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Downgrade is a no-op — we don't want to remove PKs
|
||||
pass
|
||||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-10-23 03:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '4ace53fd72c8'
|
||||
down_revision = 'af906e964978'
|
||||
|
|
@ -16,18 +16,6 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {c['name']: c for c in inspector.get_columns('folder')}
|
||||
|
||||
created_at_col = columns.get('created_at')
|
||||
if not created_at_col:
|
||||
return
|
||||
|
||||
# Only convert if still DateTime — skip if already BigInteger
|
||||
if isinstance(created_at_col['type'], sa.BigInteger):
|
||||
return
|
||||
|
||||
# Perform safe alterations using batch operation
|
||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||
# Step 1: Remove server defaults for created_at and updated_at
|
||||
|
|
@ -60,24 +48,20 @@ def upgrade():
|
|||
|
||||
|
||||
def downgrade():
|
||||
# Convert columns back to DateTime and restore defaults. Mirrors the
|
||||
# upgrade's postgresql_using cast — without it, Postgres can't
|
||||
# auto-cast BigInteger → timestamp and aborts with DatatypeMismatch.
|
||||
# Downgrade: Convert columns back to DateTime and restore defaults
|
||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
'created_at',
|
||||
type_=sa.DateTime(),
|
||||
existing_type=sa.BigInteger(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
postgresql_using='to_timestamp(created_at)::timestamp without time zone',
|
||||
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||
)
|
||||
batch_op.alter_column(
|
||||
'updated_at',
|
||||
type_=sa.DateTime(),
|
||||
existing_type=sa.BigInteger(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
postgresql_using='to_timestamp(updated_at)::timestamp without time zone',
|
||||
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||
onupdate=sa.func.now(), # Restoring onupdate behavior if it was there
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
"""add context summary to chat message
|
||||
|
||||
Revision ID: 4c5ce3d2f27f
|
||||
Revises: 3ff2c63645b8
|
||||
Create Date: 2026-06-18 23:48:08.310063
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4c5ce3d2f27f'
|
||||
down_revision: Union[str, None] = '3ff2c63645b8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('chat_message')}
|
||||
|
||||
if 'context_summary' not in columns:
|
||||
op.add_column('chat_message', sa.Column('context_summary', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('chat_message')}
|
||||
|
||||
if 'context_summary' in columns:
|
||||
op.drop_column('chat_message', 'context_summary')
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"""add pinned_note table
|
||||
|
||||
Revision ID: 4de81c2a3af1
|
||||
Revises: 56359461a091
|
||||
Create Date: 2026-05-09 04:29:27.651341
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4de81c2a3af1'
|
||||
down_revision: Union[str, None] = '56359461a091'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import insert, select, update
|
||||
from sqlalchemy.sql import column, table
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'pinned_note' not in existing_tables:
|
||||
op.create_table(
|
||||
'pinned_note',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
|
||||
)
|
||||
|
||||
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table(
|
||||
'pinned_note',
|
||||
column('id', sa.Text),
|
||||
column('user_id', sa.Text),
|
||||
column('note_id', sa.Text),
|
||||
column('created_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
notes = conn.execute(
|
||||
select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)
|
||||
).fetchall()
|
||||
|
||||
if notes:
|
||||
now = int(time.time_ns())
|
||||
conn.execute(
|
||||
insert(pinned_note_table),
|
||||
[
|
||||
{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now}
|
||||
for note in notes
|
||||
],
|
||||
)
|
||||
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_pinned')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True))
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
note_table = table('note', column('id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table('pinned_note', column('note_id', sa.Text))
|
||||
|
||||
notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall()
|
||||
|
||||
for note in notes:
|
||||
conn.execute(update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True))
|
||||
|
||||
op.drop_table('pinned_note')
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"""Add memory (id, user_id) covering index
|
||||
|
||||
Revision ID: 55f1302ac17c
|
||||
Revises: b0018471bbbe
|
||||
Create Date: 2026-07-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = '55f1302ac17c'
|
||||
down_revision: Union[str, None] = 'b0018471bbbe'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_id_user_id' not in indexes:
|
||||
op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_id_user_id' in indexes:
|
||||
op.drop_index('ix_memory_id_user_id', table_name='memory')
|
||||
|
|
@ -8,8 +8,9 @@ Create Date: 2026-04-19 16:20:58.162045
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '56359461a091'
|
||||
|
|
@ -18,86 +19,58 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _index_exists(inspector, index_name, table_name):
|
||||
"""Check if an index already exists on the given table."""
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
op.create_table(
|
||||
'calendar',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
|
||||
|
||||
if 'calendar' not in tables:
|
||||
op.create_table(
|
||||
'calendar',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table(
|
||||
'calendar_event',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('calendar_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('start_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('end_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('all_day', sa.Boolean(), nullable=False),
|
||||
sa.Column('rrule', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('location', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
|
||||
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_user', 'calendar'):
|
||||
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
|
||||
|
||||
if 'calendar_event' not in tables:
|
||||
op.create_table(
|
||||
'calendar_event',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('calendar_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('start_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('end_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('all_day', sa.Boolean(), nullable=False),
|
||||
sa.Column('rrule', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('location', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar_event' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_event_calendar', 'calendar_event'):
|
||||
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
|
||||
if not _index_exists(inspector, 'ix_calendar_event_user_date', 'calendar_event'):
|
||||
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
|
||||
|
||||
if 'calendar_event_attendee' not in tables:
|
||||
op.create_table(
|
||||
'calendar_event_attendee',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('event_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar_event_attendee' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_event_attendee_user', 'calendar_event_attendee'):
|
||||
op.create_index(
|
||||
'ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
'calendar_event_attendee',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('event_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
|
||||
)
|
||||
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-12-22 03:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '57c599a3cb57'
|
||||
down_revision = '922e7a387820'
|
||||
|
|
@ -16,36 +16,30 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
op.create_table(
|
||||
'channel',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('name', sa.Text()),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if 'channel' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('name', sa.Text()),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if 'message' not in existing_tables:
|
||||
op.create_table(
|
||||
'message',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('channel_id', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text()),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
'message',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('channel_id', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text()),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ Create Date: 2025-12-10 15:11:39.424601
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6283dc0e4d8d'
|
||||
|
|
@ -20,38 +20,31 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'channel_file' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_channel_file_channel_id', 'channel_id'),
|
||||
sa.Index('ix_channel_file_file_id', 'file_id'),
|
||||
sa.Index('ix_channel_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint(
|
||||
'channel_id', 'file_id', name='uq_channel_file_channel_file'
|
||||
), # prevent duplicate entries
|
||||
)
|
||||
op.create_table(
|
||||
'channel_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_channel_file_channel_id', 'channel_id'),
|
||||
sa.Index('ix_channel_file_file_id', 'file_id'),
|
||||
sa.Index('ix_channel_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ Create Date: 2024-10-01 14:02:35.241684
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import column, select, table
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, column, select
|
||||
import json
|
||||
|
||||
revision = '6a39f3d8e55c'
|
||||
down_revision = 'c0fbf31ca0db'
|
||||
|
|
@ -19,67 +18,62 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
# Creating the 'knowledge' table
|
||||
print('Creating knowledge table')
|
||||
knowledge_table = op.create_table(
|
||||
'knowledge',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if 'knowledge' not in existing_tables:
|
||||
# Creating the 'knowledge' table
|
||||
print('Creating knowledge table')
|
||||
knowledge_table = op.create_table(
|
||||
'knowledge',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
print('Migrating data from document table to knowledge table')
|
||||
# Representation of the existing 'document' table
|
||||
document_table = table(
|
||||
'document',
|
||||
column('collection_name', sa.String()),
|
||||
column('user_id', sa.String()),
|
||||
column('name', sa.String()),
|
||||
column('title', sa.Text()),
|
||||
column('content', sa.Text()),
|
||||
column('timestamp', sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Select all from existing document table
|
||||
documents = op.get_bind().execute(
|
||||
select(
|
||||
document_table.c.collection_name,
|
||||
document_table.c.user_id,
|
||||
document_table.c.name,
|
||||
document_table.c.title,
|
||||
document_table.c.content,
|
||||
document_table.c.timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
print('Migrating data from document table to knowledge table')
|
||||
# Representation of the existing 'document' table
|
||||
document_table = table(
|
||||
'document',
|
||||
column('collection_name', sa.String()),
|
||||
column('user_id', sa.String()),
|
||||
column('name', sa.String()),
|
||||
column('title', sa.Text()),
|
||||
column('content', sa.Text()),
|
||||
column('timestamp', sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Select all from existing document table
|
||||
documents = conn.execute(
|
||||
select(
|
||||
document_table.c.collection_name,
|
||||
document_table.c.user_id,
|
||||
document_table.c.name,
|
||||
document_table.c.title,
|
||||
document_table.c.content,
|
||||
document_table.c.timestamp,
|
||||
# Insert data into knowledge table from document table
|
||||
for doc in documents:
|
||||
op.get_bind().execute(
|
||||
knowledge_table.insert().values(
|
||||
id=doc.collection_name,
|
||||
user_id=doc.user_id,
|
||||
description=doc.name,
|
||||
meta={
|
||||
'legacy': True,
|
||||
'document': True,
|
||||
'tags': json.loads(doc.content or '{}').get('tags', []),
|
||||
},
|
||||
name=doc.title,
|
||||
created_at=doc.timestamp,
|
||||
updated_at=doc.timestamp, # using created_at for both created_at and updated_at in project
|
||||
)
|
||||
)
|
||||
|
||||
# Insert data into knowledge table from document table
|
||||
for doc in documents:
|
||||
conn.execute(
|
||||
knowledge_table.insert().values(
|
||||
id=doc.collection_name,
|
||||
user_id=doc.user_id,
|
||||
description=doc.name,
|
||||
meta={
|
||||
'legacy': True,
|
||||
'document': True,
|
||||
'tags': json.loads(doc.content or '{}').get('tags', []),
|
||||
},
|
||||
name=doc.title,
|
||||
created_at=doc.timestamp,
|
||||
updated_at=doc.timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('knowledge')
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-12-23 03:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '7826ab40b532'
|
||||
down_revision = '57c599a3cb57'
|
||||
|
|
@ -16,15 +16,10 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
if 'access_control' not in file_cols:
|
||||
op.add_column(
|
||||
'file',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'file',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
"""add memory type
|
||||
|
||||
Revision ID: 7b3f2a9c1d4e
|
||||
Revises: 4c5ce3d2f27f
|
||||
Create Date: 2026-06-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '7b3f2a9c1d4e'
|
||||
down_revision: Union[str, None] = '4c5ce3d2f27f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('memory')}
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'type' not in columns:
|
||||
op.add_column('memory', sa.Column('type', sa.String(), server_default='context', nullable=False))
|
||||
|
||||
if 'ix_memory_type' not in indexes:
|
||||
op.create_index('ix_memory_type', 'memory', ['type'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('memory')}
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_type' in indexes:
|
||||
op.drop_index('ix_memory_type', table_name='memory')
|
||||
|
||||
if 'type' in columns:
|
||||
op.drop_column('memory', 'type')
|
||||
|
|
@ -1,34 +1,44 @@
|
|||
# Initial bootstrap migration version.
|
||||
# Revision ID: 7e5b5dc7342b
|
||||
# Revises: (none)
|
||||
# Created on: 2024-06-24 13:15:33.808998
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
import open_webui.internal.db # noqa: F401
|
||||
"""init
|
||||
|
||||
Revision ID: 7e5b5dc7342b
|
||||
Revises:
|
||||
Create Date: 2024-06-24 13:15:33.808998
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import open_webui.internal.db
|
||||
from open_webui.internal.db import JSONField
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7e5b5dc7342b'
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
# Initial schema table declarations
|
||||
_INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
(
|
||||
'auth',
|
||||
[
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
if 'auth' not in existing_tables:
|
||||
op.create_table(
|
||||
'auth',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('password', sa.Text(), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'chat',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'chat' not in existing_tables:
|
||||
op.create_table(
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
|
|
@ -37,23 +47,24 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
|||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('share_id', sa.Text(), nullable=True),
|
||||
sa.Column('archived', sa.Boolean(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('share_id')],
|
||||
),
|
||||
(
|
||||
'chatidtag',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('share_id'),
|
||||
)
|
||||
|
||||
if 'chatidtag' not in existing_tables:
|
||||
op.create_table(
|
||||
'chatidtag',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('tag_name', sa.String(), nullable=True),
|
||||
sa.Column('chat_id', sa.String(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'document',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'document' not in existing_tables:
|
||||
op.create_table(
|
||||
'document',
|
||||
sa.Column('collection_name', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
|
|
@ -61,23 +72,24 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
|||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('collection_name'), sa.UniqueConstraint('name')],
|
||||
),
|
||||
(
|
||||
'file',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('collection_name'),
|
||||
sa.UniqueConstraint('name'),
|
||||
)
|
||||
|
||||
if 'file' not in existing_tables:
|
||||
op.create_table(
|
||||
'file',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('filename', sa.Text(), nullable=True),
|
||||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'function',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'function' not in existing_tables:
|
||||
op.create_table(
|
||||
'function',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
|
|
@ -89,23 +101,23 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
|||
sa.Column('is_global', sa.Boolean(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'memory',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'memory' not in existing_tables:
|
||||
op.create_table(
|
||||
'memory',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'model',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'model' not in existing_tables:
|
||||
op.create_table(
|
||||
'model',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('base_model_id', sa.Text(), nullable=True),
|
||||
|
|
@ -114,33 +126,33 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
|||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'prompt',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'prompt' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt',
|
||||
sa.Column('command', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('command')],
|
||||
),
|
||||
(
|
||||
'tag',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('command'),
|
||||
)
|
||||
|
||||
if 'tag' not in existing_tables:
|
||||
op.create_table(
|
||||
'tag',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('data', sa.Text(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'tool',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'tool' not in existing_tables:
|
||||
op.create_table(
|
||||
'tool',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
|
|
@ -150,12 +162,12 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
|||
sa.Column('valves', JSONField(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'user',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'user' not in existing_tables:
|
||||
op.create_table(
|
||||
'user',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
|
|
@ -168,25 +180,25 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
|||
sa.Column('settings', JSONField(), nullable=True),
|
||||
sa.Column('info', JSONField(), nullable=True),
|
||||
sa.Column('oauth_sub', sa.Text(), nullable=True),
|
||||
],
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('api_key'),
|
||||
sa.UniqueConstraint('oauth_sub'),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
# --- migration execution ---
|
||||
def upgrade() -> None: # deploy initial schema tables
|
||||
existing_tables = set(get_existing_tables())
|
||||
for name, columns, constraints in _INITIAL_TABLES:
|
||||
if name not in existing_tables:
|
||||
op.create_table(name, *columns, *constraints)
|
||||
|
||||
|
||||
# --- rollback function ---
|
||||
def downgrade() -> None: # rollback initial schema tables
|
||||
for table_name, _, _ in reversed(_INITIAL_TABLES):
|
||||
op.drop_table(table_name)
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('user')
|
||||
op.drop_table('tool')
|
||||
op.drop_table('tag')
|
||||
op.drop_table('prompt')
|
||||
op.drop_table('model')
|
||||
op.drop_table('memory')
|
||||
op.drop_table('function')
|
||||
op.drop_table('file')
|
||||
op.drop_table('document')
|
||||
op.drop_table('chatidtag')
|
||||
op.drop_table('chat')
|
||||
op.drop_table('auth')
|
||||
# ### end Alembic commands ###
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ Create Date: 2025-12-10 16:07:58.001282
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '81cc2ce44d79'
|
||||
|
|
@ -20,27 +20,20 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Add message_id column to channel_file table
|
||||
cf_cols = {c['name'] for c in inspector.get_columns('channel_file')}
|
||||
if 'message_id' not in cf_cols:
|
||||
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'message_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
|
||||
nullable=True,
|
||||
)
|
||||
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'message_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Add data column to knowledge table
|
||||
k_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
||||
if 'data' not in k_cols:
|
||||
with op.batch_alter_table('knowledge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
|
||||
with op.batch_alter_table('knowledge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ Create Date: 2026-02-01 04:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -56,13 +56,6 @@ def _flush_batch(conn, table, batch):
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'chat_message' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
# Step 1: Create table
|
||||
op.create_table(
|
||||
'chat_message',
|
||||
|
|
@ -92,6 +85,8 @@ def upgrade() -> None:
|
|||
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
|
||||
|
||||
# Step 2: Backfill from existing chats
|
||||
conn = op.get_bind()
|
||||
|
||||
chat_table = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.Text()),
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
"""add chat message meta
|
||||
|
||||
Revision ID: 856c5b02fb54
|
||||
Revises: 42e2978c7933
|
||||
Create Date: 2026-07-16 01:39:39.291935
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '856c5b02fb54'
|
||||
down_revision: Union[str, None] = '42e2978c7933'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat_message', 'meta')
|
||||
|
|
@ -8,9 +8,9 @@ Create Date: 2025-11-30 06:33:38.790341
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '90ef40d4714e'
|
||||
|
|
@ -20,53 +20,42 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Update 'channel' table
|
||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
||||
if 'is_private' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
|
||||
if 'archived_at' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
|
||||
if 'archived_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
|
||||
if 'deleted_at' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
|
||||
if 'deleted_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
|
||||
if 'updated_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
|
||||
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
|
||||
|
||||
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
|
||||
|
||||
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
|
||||
|
||||
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
|
||||
|
||||
# Update 'channel_member' table
|
||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
||||
if 'role' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
|
||||
if 'invited_by' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
|
||||
if 'invited_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
# Create 'channel_webhook' table
|
||||
if 'channel_webhook' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_webhook',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
'channel_webhook',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
@ -85,3 +74,5 @@ def downgrade() -> None:
|
|||
|
||||
# Drop 'channel_webhook' table
|
||||
op.drop_table('channel_webhook')
|
||||
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-11-14 03:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '922e7a387820'
|
||||
down_revision = '4ace53fd72c8'
|
||||
|
|
@ -16,55 +16,70 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'group' not in existing_tables:
|
||||
op.create_table(
|
||||
'group',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('permissions', sa.JSON(), nullable=True),
|
||||
sa.Column('user_ids', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
'group',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('permissions', sa.JSON(), nullable=True),
|
||||
sa.Column('user_ids', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'model' table
|
||||
model_cols = {c['name'] for c in inspector.get_columns('model')}
|
||||
if 'access_control' not in model_cols:
|
||||
op.add_column('model', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
if 'is_active' not in model_cols:
|
||||
op.add_column(
|
||||
'model',
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
||||
)
|
||||
op.add_column(
|
||||
'model',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'is_active' column to 'model' table
|
||||
op.add_column(
|
||||
'model',
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'knowledge' table
|
||||
knowledge_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
||||
if 'access_control' not in knowledge_cols:
|
||||
op.add_column('knowledge', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
'knowledge',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'prompt' table
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'access_control' not in prompt_cols:
|
||||
op.add_column('prompt', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
'prompt',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'tools' table
|
||||
tool_cols = {c['name'] for c in inspector.get_columns('tool')}
|
||||
if 'access_control' not in tool_cols:
|
||||
op.add_column('tool', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
'tool',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('group')
|
||||
|
||||
# Drop 'access_control' column from 'model' table
|
||||
op.drop_column('model', 'access_control')
|
||||
|
||||
# Drop 'is_active' column from 'model' table
|
||||
op.drop_column('model', 'is_active')
|
||||
|
||||
# Drop 'access_control' column from 'knowledge' table
|
||||
op.drop_column('knowledge', 'access_control')
|
||||
|
||||
# Drop 'access_control' column from 'prompt' table
|
||||
op.drop_column('prompt', 'access_control')
|
||||
|
||||
# Drop 'access_control' column from 'tools' table
|
||||
op.drop_column('tool', 'access_control')
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
"""add automation folder id
|
||||
|
||||
Revision ID: 959eaac8f909
|
||||
Revises: 55f1302ac17c
|
||||
Create Date: 2026-07-26 19:19:31.345756
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '959eaac8f909'
|
||||
down_revision: str | None = '55f1302ac17c'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
|
||||
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
|
||||
return
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {col['name'] for col in inspector.get_columns('automation')}
|
||||
indexes = {index['name'] for index in inspector.get_indexes('automation')}
|
||||
|
||||
if 'folder_id' not in columns:
|
||||
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
|
||||
|
||||
if 'ix_automation_user_folder' not in indexes:
|
||||
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.drop_index('ix_automation_user_folder', table_name='automation')
|
||||
op.drop_column('automation', 'folder_id')
|
||||
return
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {col['name'] for col in inspector.get_columns('automation')}
|
||||
indexes = {index['name'] for index in inspector.get_indexes('automation')}
|
||||
|
||||
if 'ix_automation_user_folder' in indexes:
|
||||
op.drop_index('ix_automation_user_folder', table_name='automation')
|
||||
|
||||
if 'folder_id' in columns:
|
||||
op.drop_column('automation', 'folder_id')
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
"""add current_message_id to chat
|
||||
|
||||
Revision ID: 9a1b2c3d4e5f
|
||||
Revises: 856c5b02fb54
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '9a1b2c3d4e5f'
|
||||
down_revision: Union[str, None] = '856c5b02fb54'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
BATCH_SIZE = 150
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
if 'current_message_id' not in columns:
|
||||
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
|
||||
|
||||
chat = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.String()),
|
||||
sa.column('chat', sa.Text()),
|
||||
sa.column('current_message_id', sa.Text()),
|
||||
)
|
||||
chat_message = sa.table(
|
||||
'chat_message',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('chat_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
has_chat_message = 'chat_message' in inspector.get_table_names()
|
||||
result = conn.execute(
|
||||
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
|
||||
yield_per=BATCH_SIZE,
|
||||
stream_results=True,
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
rows = result.fetchmany(BATCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
batch_chat_ids: list[str] = []
|
||||
candidates_by_chat: dict[str, list[str]] = {}
|
||||
current_by_chat: dict[str, str | None] = {}
|
||||
json_messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
|
||||
for row in rows:
|
||||
values = row._mapping
|
||||
chat_id = values['id']
|
||||
prefix = f'{chat_id}-'
|
||||
batch_chat_ids.append(chat_id)
|
||||
current_by_chat[chat_id] = values['current_message_id']
|
||||
|
||||
chat_data = {}
|
||||
if isinstance(values['chat'], dict):
|
||||
chat_data = values['chat']
|
||||
elif isinstance(values['chat'], str):
|
||||
try:
|
||||
parsed = json.loads(values['chat'])
|
||||
chat_data = parsed if isinstance(parsed, dict) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
|
||||
candidates_by_chat[chat_id] = []
|
||||
for candidate in (
|
||||
values['current_message_id'],
|
||||
history.get('currentId'),
|
||||
chat_data.get('currentId'),
|
||||
chat_data.get('branchPointMessageId'),
|
||||
):
|
||||
if not isinstance(candidate, str) or not candidate:
|
||||
continue
|
||||
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
|
||||
if candidate not in candidates_by_chat[chat_id]:
|
||||
candidates_by_chat[chat_id].append(candidate)
|
||||
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(chat_data.get('messages'), list):
|
||||
messages = {
|
||||
message['id']: message
|
||||
for message in chat_data['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
if messages:
|
||||
json_messages_by_chat[chat_id] = {
|
||||
message_id: {
|
||||
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
|
||||
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
|
||||
}
|
||||
for message_id, message in messages.items()
|
||||
}
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
|
||||
if has_chat_message:
|
||||
candidate_ids = {
|
||||
f'{chat_id}-{candidate}'
|
||||
for chat_id, candidates in candidates_by_chat.items()
|
||||
for candidate in candidates
|
||||
}
|
||||
if candidate_ids:
|
||||
valid_by_chat: dict[str, set[str]] = {}
|
||||
for row in conn.execute(
|
||||
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
|
||||
chat_message.c.chat_id.in_(batch_chat_ids),
|
||||
chat_message.c.id.in_(candidate_ids),
|
||||
)
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if message_id:
|
||||
valid_by_chat.setdefault(chat_id, set()).add(message_id)
|
||||
for chat_id, candidates in candidates_by_chat.items():
|
||||
valid_ids = valid_by_chat.get(chat_id, set())
|
||||
for candidate in candidates:
|
||||
if candidate in valid_ids:
|
||||
resolved[chat_id] = candidate
|
||||
break
|
||||
|
||||
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
|
||||
messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
if unresolved_chat_ids:
|
||||
for row in conn.execute(
|
||||
sa.select(
|
||||
chat_message.c.chat_id,
|
||||
chat_message.c.id,
|
||||
chat_message.c.parent_id,
|
||||
chat_message.c.created_at,
|
||||
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if not message_id:
|
||||
continue
|
||||
parent_id = values['parent_id']
|
||||
if parent_id and parent_id.startswith(prefix):
|
||||
parent_id = parent_id[len(prefix) :]
|
||||
messages_by_chat.setdefault(chat_id, {})[message_id] = {
|
||||
'parent_id': parent_id,
|
||||
'created_at': values['created_at'] or 0,
|
||||
}
|
||||
|
||||
for chat_id, messages in messages_by_chat.items():
|
||||
parent_ids = {
|
||||
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
for chat_id in batch_chat_ids:
|
||||
if chat_id in resolved:
|
||||
continue
|
||||
|
||||
messages = json_messages_by_chat.get(chat_id, {})
|
||||
valid_candidate = next(
|
||||
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
|
||||
None,
|
||||
)
|
||||
if valid_candidate:
|
||||
resolved[chat_id] = valid_candidate
|
||||
elif messages:
|
||||
parent_ids = {
|
||||
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
updates = [
|
||||
{'chat_id': chat_id, 'current_message_id': message_id}
|
||||
for chat_id, message_id in resolved.items()
|
||||
if message_id and message_id != current_by_chat.get(chat_id)
|
||||
]
|
||||
if updates:
|
||||
conn.execute(
|
||||
sa.update(chat)
|
||||
.where(chat.c.id == sa.bindparam('update_chat_id'))
|
||||
.values(current_message_id=sa.bindparam('update_current_message_id')),
|
||||
[
|
||||
{
|
||||
'update_chat_id': row['chat_id'],
|
||||
'update_current_message_id': row['current_message_id'],
|
||||
}
|
||||
for row in updates
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'current_message_id')
|
||||
|
|
@ -6,8 +6,8 @@ Create Date: 2025-05-03 03:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '9f0c9cd09105'
|
||||
down_revision = '3781e22d8b01'
|
||||
|
|
@ -16,22 +16,17 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'note' not in existing_tables:
|
||||
op.create_table(
|
||||
'note',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
'note',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
"""Add memory user_id index
|
||||
|
||||
Revision ID: a0b1c2d3e4f5
|
||||
Revises: 4de81c2a3af1
|
||||
Create Date: 2025-09-15 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'a0b1c2d3e4f5'
|
||||
down_revision = '4de81c2a3af1'
|
||||
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('memory')}
|
||||
|
||||
if 'ix_memory_user_id' not in existing_indexes:
|
||||
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_memory_user_id', table_name='memory')
|
||||
|
|
@ -8,8 +8,9 @@ Create Date: 2026-02-11 09:30:00.000000
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Create Date: 2026-03-29 22:15:00.000000
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3dd5bedd151'
|
||||
|
|
@ -19,14 +19,8 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'tasks' not in columns:
|
||||
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
|
||||
if 'summary' not in columns:
|
||||
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
|
||||
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
|
||||
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Create Date: 2025-09-27 02:24:18.058455
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a5c220713937'
|
||||
|
|
@ -19,18 +19,16 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
msg_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
|
||||
# Add 'reply_to_id' column to the 'message' table for replying to messages
|
||||
if 'reply_to_id' not in msg_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('reply_to_id', sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('reply_to_id', sa.Text(), nullable=True),
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove 'reply_to_id' column from the 'message' table
|
||||
op.drop_column('message', 'reply_to_id')
|
||||
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-10-20 17:02:35.241684
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# Revision identifiers, used by Alembic.
|
||||
revision = 'af906e964978'
|
||||
|
|
@ -17,28 +17,23 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'feedback' not in existing_tables:
|
||||
# ### Create feedback table ###
|
||||
op.create_table(
|
||||
'feedback',
|
||||
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
|
||||
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
|
||||
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
|
||||
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
||||
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
|
||||
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
|
||||
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
|
||||
sa.Column(
|
||||
'created_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback creation timestamp (BIGINT representing epoch)
|
||||
sa.Column(
|
||||
'updated_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback update timestamp (BIGINT representing epoch)
|
||||
)
|
||||
# ### Create feedback table ###
|
||||
op.create_table(
|
||||
'feedback',
|
||||
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
|
||||
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
|
||||
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
|
||||
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
||||
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
|
||||
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
|
||||
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
|
||||
sa.Column(
|
||||
'created_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback creation timestamp (BIGINT representing epoch)
|
||||
sa.Column(
|
||||
'updated_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback update timestamp (BIGINT representing epoch)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
"""add user variables
|
||||
|
||||
Revision ID: b0018471bbbe
|
||||
Revises: c49178636c78
|
||||
Create Date: 2026-07-24 01:21:46.457057
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b0018471bbbe'
|
||||
down_revision: Union[str, None] = 'c49178636c78'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('user')]
|
||||
|
||||
if 'variables' not in columns:
|
||||
op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'variables')
|
||||
|
|
@ -6,13 +6,15 @@ Create Date: 2025-11-28 04:55:31.737538
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
import open_webui.internal.db
|
||||
import json
|
||||
import time
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b10670c03dd5'
|
||||
|
|
@ -20,49 +22,20 @@ down_revision: Union[str, None] = '2f1211949ecc'
|
|||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# ── Ad-hoc table references for Core DML ─────────────────────────────────
|
||||
# These are lightweight table() / column() references used only inside this
|
||||
# migration for SELECT / UPDATE / INSERT — they do NOT create or alter
|
||||
# anything on disk.
|
||||
|
||||
_user = sa.table(
|
||||
'user',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('oauth_sub', sa.Text),
|
||||
sa.column('oauth', sa.JSON),
|
||||
sa.column('api_key', sa.Text),
|
||||
sa.column('info', sa.Text),
|
||||
sa.column('settings', sa.Text),
|
||||
)
|
||||
|
||||
_api_key = sa.table(
|
||||
'api_key',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('user_id', sa.Text),
|
||||
sa.column('key', sa.Text),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
|
||||
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
|
||||
"""
|
||||
SQLite requires manual removal of any user-created indexes referencing
|
||||
a column before ALTER TABLE ... DROP COLUMN can succeed.
|
||||
|
||||
NOTE: PRAGMAs have no Core equivalent — raw text is unavoidable here.
|
||||
SQLite requires manual removal of any indexes referencing a column
|
||||
before ALTER TABLE ... DROP COLUMN can succeed.
|
||||
"""
|
||||
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
|
||||
|
||||
for idx in indexes:
|
||||
index_name = idx[1] # index name
|
||||
# Skip system-managed autoindexes (PK / UNIQUE constraints) — they
|
||||
# cannot be dropped directly and will disappear when the column is
|
||||
# removed via batch_alter_table.
|
||||
if index_name.startswith('sqlite_autoindex_'):
|
||||
continue
|
||||
# Get indexed columns
|
||||
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
|
||||
indexed_cols = [row[2] for row in idx_info]
|
||||
|
||||
indexed_cols = [row[2] for row in idx_info] # col names
|
||||
if column_name in indexed_cols:
|
||||
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
|
||||
|
||||
|
|
@ -71,30 +44,33 @@ def _convert_column_to_json(table: str, column: str):
|
|||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
|
||||
|
||||
# SQLite cannot ALTER COLUMN → must recreate column
|
||||
if dialect == 'sqlite':
|
||||
# 1. Add temporary column
|
||||
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
|
||||
|
||||
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
|
||||
# 2. Load old data
|
||||
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||
|
||||
for uid, raw in rows:
|
||||
for row in rows:
|
||||
uid, raw = row
|
||||
if raw is None:
|
||||
parsed = None
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None
|
||||
parsed = None # fallback safe behavior
|
||||
|
||||
conn.execute(
|
||||
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_json', sa.JSON)))
|
||||
.where(sa.column('id') == uid)
|
||||
.values({f'{column}_json': parsed})
|
||||
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
|
||||
{'val': json.dumps(parsed) if parsed else None, 'id': uid},
|
||||
)
|
||||
|
||||
# 3. Drop old TEXT column
|
||||
op.drop_column(table, column)
|
||||
|
||||
# 4. Rename new JSON column → original name
|
||||
op.alter_column(table, f'{column}_json', new_column_name=column)
|
||||
|
||||
else:
|
||||
|
|
@ -111,18 +87,15 @@ def _convert_column_to_text(table: str, column: str):
|
|||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.JSON))
|
||||
|
||||
if dialect == 'sqlite':
|
||||
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
|
||||
|
||||
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
|
||||
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||
|
||||
for uid, raw in rows:
|
||||
conn.execute(
|
||||
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_text', sa.Text)))
|
||||
.where(sa.column('id') == uid)
|
||||
.values({f'{column}_text': json.dumps(raw) if raw is not None else None})
|
||||
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
|
||||
{'val': json.dumps(raw) if raw else None, 'id': uid},
|
||||
)
|
||||
|
||||
op.drop_column(table, column)
|
||||
|
|
@ -138,110 +111,115 @@ def _convert_column_to_text(table: str, column: str):
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('user', sa.Column('profile_banner_image_url', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('timezone', sa.String(), nullable=True))
|
||||
|
||||
op.add_column('user', sa.Column('presence_state', sa.String(), nullable=True))
|
||||
op.add_column('user', sa.Column('status_emoji', sa.String(), nullable=True))
|
||||
op.add_column('user', sa.Column('status_message', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('status_expires_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
op.add_column('user', sa.Column('oauth', sa.JSON(), nullable=True))
|
||||
|
||||
# Convert info (TEXT/JSONField) → JSON
|
||||
_convert_column_to_json('user', 'info')
|
||||
# Convert settings (TEXT/JSONField) → JSON
|
||||
_convert_column_to_json('user', 'settings')
|
||||
|
||||
op.create_table(
|
||||
'api_key',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
|
||||
sa.Column('key', sa.Text(), unique=True, nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
user_columns = {c['name'] for c in inspector.get_columns('user')}
|
||||
users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall()
|
||||
|
||||
# ── Add new columns (idempotent) ──────────────────────────────────
|
||||
for col_name, col_type in [
|
||||
('profile_banner_image_url', sa.Text()),
|
||||
('timezone', sa.String()),
|
||||
('presence_state', sa.String()),
|
||||
('status_emoji', sa.String()),
|
||||
('status_message', sa.Text()),
|
||||
('status_expires_at', sa.BigInteger()),
|
||||
('oauth', sa.JSON()),
|
||||
]:
|
||||
if col_name not in user_columns:
|
||||
op.add_column('user', sa.Column(col_name, col_type, nullable=True))
|
||||
for uid, oauth_sub in users:
|
||||
if oauth_sub:
|
||||
# Example formats supported:
|
||||
# provider@sub
|
||||
# plain sub (stored as {"oidc": {"sub": sub}})
|
||||
if '@' in oauth_sub:
|
||||
provider, sub = oauth_sub.split('@', 1)
|
||||
else:
|
||||
provider, sub = 'oidc', oauth_sub
|
||||
|
||||
# Convert info (TEXT/JSONField) → JSON (skip if already JSON)
|
||||
user_col_types = {c['name']: c['type'] for c in inspector.get_columns('user')}
|
||||
if isinstance(user_col_types.get('info'), sa.Text):
|
||||
_convert_column_to_json('user', 'info')
|
||||
# Convert settings (TEXT/JSONField) → JSON (skip if already JSON)
|
||||
if isinstance(user_col_types.get('settings'), sa.Text):
|
||||
_convert_column_to_json('user', 'settings')
|
||||
oauth_json = json.dumps({provider: {'sub': sub}})
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
|
||||
{'oauth': oauth_json, 'id': uid},
|
||||
)
|
||||
|
||||
# ── Create api_key table (idempotent) ─────────────────────────────
|
||||
if 'api_key' not in existing_tables:
|
||||
op.create_table(
|
||||
'api_key',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
|
||||
sa.Column('key', sa.Text(), unique=True, nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall()
|
||||
now = int(time.time())
|
||||
|
||||
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
|
||||
if 'oauth_sub' in user_columns:
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
|
||||
for uid, api_key in users_with_keys:
|
||||
if api_key:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
|
||||
VALUES (:id, :user_id, :key, :created_at, :updated_at)
|
||||
"""),
|
||||
{
|
||||
'id': f'key_{uid}',
|
||||
'user_id': uid,
|
||||
'key': api_key,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
)
|
||||
|
||||
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={provider: {'sub': sub}}))
|
||||
if conn.dialect.name == 'sqlite':
|
||||
_drop_sqlite_indexes_for_column('user', 'api_key', conn)
|
||||
_drop_sqlite_indexes_for_column('user', 'oauth_sub', conn)
|
||||
|
||||
# ── Migrate api_key column → api_key table (only if old column still exists)
|
||||
if 'api_key' in user_columns:
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
|
||||
now = int(time.time())
|
||||
|
||||
for uid, key_val in rows:
|
||||
if key_val:
|
||||
conn.execute(
|
||||
sa.insert(_api_key).values(
|
||||
id=f'key_{uid}',
|
||||
user_id=uid,
|
||||
key=key_val,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Drop legacy columns (idempotent) ──────────────────────────────
|
||||
cols_to_drop = {'api_key', 'oauth_sub'} & user_columns
|
||||
if cols_to_drop:
|
||||
if conn.dialect.name == 'sqlite':
|
||||
for col in cols_to_drop:
|
||||
_drop_sqlite_indexes_for_column('user', col, conn)
|
||||
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
for col in cols_to_drop:
|
||||
batch_op.drop_column(col)
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
batch_op.drop_column('api_key')
|
||||
batch_op.drop_column('oauth_sub')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# --- 1. Restore old oauth_sub column ---
|
||||
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
|
||||
users = conn.execute(sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')).fetchall()
|
||||
|
||||
for uid, oauth in rows:
|
||||
for uid, oauth in users:
|
||||
try:
|
||||
data = oauth if isinstance(oauth, dict) else json.loads(oauth)
|
||||
data = json.loads(oauth)
|
||||
provider = list(data.keys())[0]
|
||||
sub = data[provider].get('sub')
|
||||
oauth_sub = f'{provider}@{sub}'
|
||||
except Exception:
|
||||
oauth_sub = None
|
||||
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth_sub=oauth_sub))
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
|
||||
{'oauth_sub': oauth_sub, 'id': uid},
|
||||
)
|
||||
|
||||
op.drop_column('user', 'oauth')
|
||||
|
||||
# --- Restore api_key field ---
|
||||
# --- 2. Restore api_key field ---
|
||||
op.add_column('user', sa.Column('api_key', sa.String(), nullable=True))
|
||||
|
||||
keys = conn.execute(sa.select(_api_key.c.user_id, _api_key.c.key)).fetchall()
|
||||
# Restore values from api_key
|
||||
keys = conn.execute(sa.text('SELECT user_id, key FROM api_key')).fetchall()
|
||||
for uid, key in keys:
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(api_key=key))
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
|
||||
{'key': key, 'id': uid},
|
||||
)
|
||||
|
||||
# Drop new table
|
||||
op.drop_table('api_key')
|
||||
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Create Date: 2026-02-13 14:19:00.000000
|
|||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2c3d4e5f6a7'
|
||||
|
|
@ -19,12 +19,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
user_cols = {c['name'] for c in inspector.get_columns('user')}
|
||||
|
||||
if 'scim' not in user_cols:
|
||||
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
|
||||
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ Create Date: 2026-04-01 04:00:00.000000
|
|||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b7c8d9e0f1a2'
|
||||
|
|
@ -17,14 +18,9 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'last_read_at' not in columns:
|
||||
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
# Set existing chats to be marked as read
|
||||
op.execute('UPDATE chat SET last_read_at = updated_at')
|
||||
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
# Set existing chats to be marked as read
|
||||
op.execute('UPDATE chat SET last_read_at = updated_at')
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -19,17 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
if 'hash' not in file_cols:
|
||||
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
|
||||
if 'data' not in file_cols:
|
||||
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
|
||||
if 'updated_at' not in file_cols:
|
||||
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
|
||||
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
|
||||
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ Create Date: 2026-04-16 23:00:00.000000
|
|||
import time
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'e1f2a3b4c5d6'
|
||||
|
|
@ -61,21 +61,18 @@ access_grant_t = sa.table(
|
|||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# 1. Create shared_chat table (idempotent)
|
||||
if 'shared_chat' not in tables:
|
||||
op.create_table(
|
||||
'shared_chat',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('chat', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
# 1. Create shared_chat table
|
||||
op.create_table(
|
||||
'shared_chat',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('chat', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# 2. Migrate existing shared-* rows
|
||||
shared_rows = conn.execute(
|
||||
|
|
@ -99,51 +96,31 @@ def upgrade():
|
|||
if not original:
|
||||
continue
|
||||
|
||||
# Check if shared_chat record already exists (idempotent)
|
||||
existing_shared = conn.execute(
|
||||
sa.select(shared_chat_t.c.id).where(shared_chat_t.c.id == share_token)
|
||||
).fetchone()
|
||||
|
||||
if not existing_shared:
|
||||
# Insert snapshot into shared_chat
|
||||
conn.execute(
|
||||
shared_chat_t.insert().values(
|
||||
id=share_token,
|
||||
chat_id=original_chat_id,
|
||||
user_id=original.user_id,
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
# Insert snapshot into shared_chat
|
||||
conn.execute(
|
||||
shared_chat_t.insert().values(
|
||||
id=share_token,
|
||||
chat_id=original_chat_id,
|
||||
user_id=original.user_id,
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# Check if access_grant record already exists (idempotent)
|
||||
existing_grant = conn.execute(
|
||||
sa.select(access_grant_t.c.id).where(
|
||||
sa.and_(
|
||||
access_grant_t.c.resource_type == 'shared_chat',
|
||||
access_grant_t.c.resource_id == original_chat_id,
|
||||
access_grant_t.c.principal_type == 'user',
|
||||
access_grant_t.c.principal_id == '*',
|
||||
access_grant_t.c.permission == 'read',
|
||||
)
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if not existing_grant:
|
||||
# Create user:*:read grant for backward compat
|
||||
conn.execute(
|
||||
access_grant_t.insert().values(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type='shared_chat',
|
||||
resource_id=original_chat_id,
|
||||
principal_type='user',
|
||||
principal_id='*',
|
||||
permission='read',
|
||||
created_at=row.created_at or int(time.time()),
|
||||
)
|
||||
# Create user:*:read grant for backward compat
|
||||
conn.execute(
|
||||
access_grant_t.insert().values(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type='shared_chat',
|
||||
resource_id=original_chat_id,
|
||||
principal_type='user',
|
||||
principal_id='*',
|
||||
permission='read',
|
||||
created_at=row.created_at or int(time.time()),
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Clean up old phantom rows
|
||||
conn.execute(
|
||||
|
|
|
|||
|
|
@ -6,12 +6,11 @@ Create Date: 2024-10-20 17:02:35.241684
|
|||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import JSON, String, Text, and_
|
||||
from sqlalchemy.sql import column, table
|
||||
import sqlalchemy as sa
|
||||
import json
|
||||
from sqlalchemy.sql import table, column
|
||||
from sqlalchemy import String, Text, JSON, and_
|
||||
|
||||
revision = 'c29facfe716b'
|
||||
down_revision = 'c69f45358db4'
|
||||
|
|
@ -20,13 +19,8 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
# 1. Add the `path` column to the "file" table.
|
||||
if 'path' not in file_cols:
|
||||
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
|
||||
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
|
||||
|
||||
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
|
||||
# Use Alembic's default batch_op for dialect compatibility.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue