mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-08 03:08:02 +00:00
Compare commits
No commits in common. "main" and "v0.9.4" have entirely different histories.
997 changed files with 152139 additions and 292674 deletions
|
|
@ -18,6 +18,3 @@ uploads
|
||||||
**/*.db
|
**/*.db
|
||||||
_test
|
_test
|
||||||
backend/data/*
|
backend/data/*
|
||||||
|
|
||||||
.venv
|
|
||||||
.git
|
|
||||||
|
|
|
||||||
26
.env.example
26
.env.example
|
|
@ -13,34 +13,10 @@ OPENAI_API_KEY=''
|
||||||
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
|
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
|
||||||
CORS_ALLOW_ORIGIN='*'
|
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
|
|
||||||
|
|
||||||
# Comma-separated chunk metadata keys to expose to the model alongside retrieved content.
|
|
||||||
RAG_SOURCE_METADATA_KEYS=''
|
|
||||||
|
|
||||||
# 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)
|
# For production you should set this to match the proxy configuration (127.0.0.1)
|
||||||
FORWARDED_ALLOW_IPS='*'
|
FORWARDED_ALLOW_IPS='*'
|
||||||
|
|
||||||
# DO NOT TRACK
|
# DO NOT TRACK
|
||||||
SCARF_NO_ANALYTICS=true
|
SCARF_NO_ANALYTICS=true
|
||||||
DO_NOT_TRACK=true
|
DO_NOT_TRACK=true
|
||||||
ANONYMIZED_TELEMETRY=false
|
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'
|
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1 +1 @@
|
||||||
github: open-webui
|
github: tjbck
|
||||||
|
|
|
||||||
136
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
136
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -1,35 +1,41 @@
|
||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Tell us what broke in Open WebUI.
|
description: Create a detailed bug report to help us improve Open WebUI.
|
||||||
title: 'issue: '
|
title: 'issue: '
|
||||||
labels: ['bug', 'triage']
|
labels: ['bug', 'triage']
|
||||||
|
assignees: []
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
# Bug Report
|
# Bug Report
|
||||||
|
|
||||||
Use this for real, reproducible bugs. A clear issue is the most useful contribution: include the affected workflow, the expected result, the actual result, and the details needed for someone else to reproduce it.
|
## Important Notes
|
||||||
|
|
||||||
Before submitting, search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions). The issue may already be reported or fixed on `dev`.
|
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues AND discussions. No matter open or closed.**
|
||||||
|
|
||||||
**Test on the latest release AND on `dev`, right before you submit this report, not last week.** A huge share of reports are for bugs already fixed on `dev`, sometimes weeks earlier, because the reporter only tested an old version and never rechecked. Reports that don't reproduce on latest and on current `dev` at submission time will be closed without further discussion, no exceptions.
|
- Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed on the dev branch!**
|
||||||
|
|
||||||
Please do not open a code pull request for this report unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Actionable reproduction details are the most useful next step.
|
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
|
||||||
|
|
||||||
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
|
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
|
||||||
|
|
||||||
|
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
|
||||||
|
|
||||||
|
- **Scope**: If you want to report a SECURITY VULNERABILITY, then do so through our [GitHub security page](https://github.com/open-webui/open-webui/security).
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: issue-check
|
id: issue-check
|
||||||
attributes:
|
attributes:
|
||||||
label: Before Submitting
|
label: Check Existing Issues
|
||||||
|
description: Confirm that you’ve checked for existing reports before submitting a new one.
|
||||||
options:
|
options:
|
||||||
- label: I searched open and closed issues and discussions for an existing report.
|
- label: I have searched for any existing and/or related issues.
|
||||||
required: true
|
required: true
|
||||||
- label: I reproduced this bug on the latest release AND on the current `dev` branch, right before submitting this report. I did not just check an old version or rely on a check from days ago.
|
- label: I have searched for any existing and/or related discussions.
|
||||||
required: true
|
required: true
|
||||||
- label: I understand that maintainers want a well-written issue before any code pull request.
|
- label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
|
||||||
required: true
|
required: true
|
||||||
- label: This is not a security vulnerability.
|
- label: I am using the latest version of Open WebUI.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|
@ -38,9 +44,9 @@ body:
|
||||||
label: Installation Method
|
label: Installation Method
|
||||||
description: How did you install Open WebUI?
|
description: How did you install Open WebUI?
|
||||||
options:
|
options:
|
||||||
- Docker
|
|
||||||
- Pip Install
|
|
||||||
- Git Clone
|
- Git Clone
|
||||||
|
- Pip Install
|
||||||
|
- Docker
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
@ -49,51 +55,67 @@ body:
|
||||||
id: open-webui-version
|
id: open-webui-version
|
||||||
attributes:
|
attributes:
|
||||||
label: Open WebUI Version
|
label: Open WebUI Version
|
||||||
description: Specify the version, commit, or image tag.
|
description: Specify the version (e.g., v0.6.26)
|
||||||
placeholder: v0.11.0, dev commit SHA, or Docker tag
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: ollama-version
|
||||||
|
attributes:
|
||||||
|
label: Ollama Version (if applicable)
|
||||||
|
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: operating-system
|
id: operating-system
|
||||||
attributes:
|
attributes:
|
||||||
label: Operating System
|
label: Operating System
|
||||||
description: Specify the OS and version.
|
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
|
||||||
placeholder: Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
label: Browser
|
label: Browser (if applicable)
|
||||||
description: If the bug appears in the browser, include browser and version.
|
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
|
||||||
placeholder: Chrome 151.0, Firefox 153.0.3
|
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
- type: input
|
- type: checkboxes
|
||||||
id: ollama-version
|
id: confirmation
|
||||||
attributes:
|
attributes:
|
||||||
label: Ollama Version
|
label: Confirmation
|
||||||
description: Include this if Ollama is involved.
|
description: Ensure the following prerequisites have been met.
|
||||||
placeholder: v0.32.5
|
options:
|
||||||
validations:
|
- label: I have read and followed all instructions in `README.md`.
|
||||||
required: false
|
required: true
|
||||||
|
- label: I am using the latest version of **both** Open WebUI and Ollama.
|
||||||
- type: textarea
|
required: true
|
||||||
id: summary
|
- label: I have included the browser console logs.
|
||||||
attributes:
|
required: true
|
||||||
label: Summary
|
- label: I have included the Docker container logs.
|
||||||
description: What is wrong, in a few sentences?
|
required: true
|
||||||
validations:
|
- label: I have **provided every relevant configuration, setting, and environment variable used in my setup.**
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc).
|
||||||
|
required: true
|
||||||
|
- label: |
|
||||||
|
I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps:
|
||||||
|
- Start with the initial platform/version/OS and dependencies used,
|
||||||
|
- Specify exact install/launch/configure commands,
|
||||||
|
- List URLs visited, user input (incl. example values/emails/passwords if needed),
|
||||||
|
- Describe all options and toggles enabled or changed,
|
||||||
|
- Include any files or environmental changes,
|
||||||
|
- Identify the expected and actual result at each stage,
|
||||||
|
- Ensure any reasonably skilled user can follow and hit the same issue.
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected-behavior
|
id: expected-behavior
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected Behavior
|
label: Expected Behavior
|
||||||
description: What should have happened?
|
description: Describe what should have happened.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
@ -101,7 +123,7 @@ body:
|
||||||
id: actual-behavior
|
id: actual-behavior
|
||||||
attributes:
|
attributes:
|
||||||
label: Actual Behavior
|
label: Actual Behavior
|
||||||
description: What actually happened?
|
description: Describe what actually happened.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
@ -109,21 +131,32 @@ body:
|
||||||
id: reproduction-steps
|
id: reproduction-steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce
|
label: Steps to Reproduce
|
||||||
description: Include the exact commands, settings, URLs, model/provider setup, and user actions needed to hit the bug.
|
description: |
|
||||||
|
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
|
||||||
|
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
|
||||||
|
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
|
||||||
|
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Start Open WebUI with ...
|
Example (include every detail):
|
||||||
2. Configure ...
|
1. Start with a clean Ubuntu 22.04 install.
|
||||||
3. Open ...
|
2. Install Docker v24.0.5 and start the service.
|
||||||
4. Click ...
|
3. Clone the Open WebUI repo (git clone ...).
|
||||||
5. See ...
|
4. Use the Docker Compose file without modifications.
|
||||||
|
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".
|
||||||
|
9. Observe that the error message "Connection refused" appears at the top right.
|
||||||
|
|
||||||
|
Please list each step carefully and include all relevant configuration, settings, and options.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs-screenshots
|
id: logs-screenshots
|
||||||
attributes:
|
attributes:
|
||||||
label: Logs, Screenshots, and Config
|
label: Logs & Screenshots
|
||||||
description: Include relevant browser console logs, server/container logs, screenshots, and configuration. If something does not apply, say so.
|
description: Include relevant logs, errors, or screenshots to help diagnose the issue.
|
||||||
|
placeholder: 'Attach logs from the browser console, Docker logs, or error messages.'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
@ -131,6 +164,13 @@ body:
|
||||||
id: additional-info
|
id: additional-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional Information
|
label: Additional Information
|
||||||
description: Anything else that might help us understand the report.
|
description: Provide any extra details that may assist in understanding the issue.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Note
|
||||||
|
**If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
|
||||||
|
Thank you for contributing to Open WebUI!
|
||||||
|
|
|
||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,5 +1 @@
|
||||||
blank_issues_enabled: false
|
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.
|
|
||||||
|
|
|
||||||
97
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
97
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -1,73 +1,82 @@
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Describe what you would like Open WebUI to support.
|
description: Suggest an idea for this project
|
||||||
title: 'feat: '
|
title: 'feat: '
|
||||||
labels: ['triage']
|
labels: ['triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
# Feature Request
|
## 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.
|
||||||
|
|
||||||
Describe the requested behavior, the problem it solves, and any examples, mockups, screenshots, or workflows that clarify the request. A clear issue or discussion is the most useful contribution.
|
#### Scope
|
||||||
|
|
||||||
Search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) before submitting. If the request needs broad product, UX, architecture, compatibility, or maintenance discussion, please start in [Discussions](https://github.com/open-webui/open-webui/discussions) so the community can weigh in.
|
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**.
|
||||||
|
|
||||||
Please do not open a code pull request for this request unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Clear product context is the most useful next step.
|
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**
|
||||||
|
|
||||||
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
|
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.
|
||||||
|
|
||||||
|
### 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
|
- type: checkboxes
|
||||||
id: existing-request
|
id: existing-issue
|
||||||
attributes:
|
attributes:
|
||||||
label: Before Submitting
|
label: Check Existing Issues
|
||||||
|
description: Please confirm that you've checked for existing similar requests
|
||||||
options:
|
options:
|
||||||
- label: I searched open and closed issues and discussions for an existing 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
|
required: true
|
||||||
- label: I checked whether this already exists on the `dev` branch or latest source.
|
- type: checkboxes
|
||||||
|
id: feature-scope
|
||||||
|
attributes:
|
||||||
|
label: Verify Feature Scope
|
||||||
|
description: Please confirm the feature's scope is within the described scope
|
||||||
|
options:
|
||||||
|
- 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
|
required: true
|
||||||
- label: I understand that maintainers want a well-written issue or discussion before any code pull request.
|
|
||||||
required: true
|
|
||||||
- label: This request is not a security vulnerability.
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem-description
|
id: problem-description
|
||||||
attributes:
|
attributes:
|
||||||
label: Problem
|
label: Problem Description
|
||||||
description: What is missing, frustrating, confusing, or unnecessarily hard today?
|
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
|
||||||
placeholder: "I'm trying to..., but..."
|
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: desired-behavior
|
id: solution-description
|
||||||
attributes:
|
attributes:
|
||||||
label: Desired Behavior
|
label: Desired Solution you'd like
|
||||||
description: What would you like to happen instead?
|
description: Clearly describe what you want to happen.
|
||||||
placeholder: "I would like Open WebUI to..."
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: why-it-matters
|
|
||||||
attributes:
|
|
||||||
label: Why This Matters
|
|
||||||
description: Who benefits, and what workflow does this unlock or improve?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: examples
|
|
||||||
attributes:
|
|
||||||
label: Examples or References
|
|
||||||
description: Add mockups, screenshots, links, prompts, workflows, or examples from other tools.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: alternatives-considered
|
id: alternatives-considered
|
||||||
attributes:
|
attributes:
|
||||||
label: Alternatives or Workarounds
|
label: Alternatives Considered
|
||||||
description: What have you tried instead, if anything?
|
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
validations:
|
- type: textarea
|
||||||
required: false
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
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: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: monthly
|
interval: monthly
|
||||||
target-branch: dev
|
target-branch: 'dev'
|
||||||
|
|
||||||
- package-ecosystem: pip
|
- package-ecosystem: pip
|
||||||
directory: '/backend'
|
directory: '/backend'
|
||||||
schedule:
|
schedule:
|
||||||
interval: monthly
|
interval: monthly
|
||||||
target-branch: dev
|
target-branch: 'dev'
|
||||||
|
|
||||||
- package-ecosystem: github-actions
|
- package-ecosystem: 'github-actions'
|
||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
|
# Check for updates to GitHub Actions every week
|
||||||
interval: monthly
|
interval: monthly
|
||||||
target-branch: dev
|
target-branch: 'dev'
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: '/'
|
|
||||||
schedule:
|
|
||||||
interval: monthly
|
|
||||||
target-branch: dev
|
|
||||||
|
|
|
||||||
125
.github/pull_request_template.md
vendored
125
.github/pull_request_template.md
vendored
|
|
@ -1,95 +1,104 @@
|
||||||
<!--
|
<!--
|
||||||
Important checks for contributors:
|
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||||
1. DO NOT OPEN A CODE PULL REQUEST unless a maintainer explicitly asked you to, or the change is strictly limited to i18n/localization.
|
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||||
2. Target the `dev` branch. PRs targeting `main` will be closed.
|
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||||
3. Do not delete the Contributor License Agreement section at the bottom. The CLA bot requires it.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# Pull Request
|
# Pull Request Checklist
|
||||||
|
|
||||||
**Do not open a code pull request unless a maintainer has explicitly requested it or the change is limited to i18n/localization.**
|
### 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.
|
||||||
|
|
||||||
The most useful way to help is to give us a clear understanding of the problem: report reproducible bugs in [Issues](https://github.com/open-webui/open-webui/issues) and share proposals in [Discussions](https://github.com/open-webui/open-webui/discussions). We use that context to evaluate solutions and refine the implementation internally, accounting for the broader codebase and ongoing work. External implementations usually require substantial reworking to fit the project's standards, and coordinating those revisions usually takes more effort than developing the solution internally. Please follow this process before investing time in a pull request. PRs opened outside these guidelines are generally closed without review.
|
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.
|
||||||
|
|
||||||
## Maintainer Request
|
<!--
|
||||||
|
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
|
||||||
|
|
||||||
Link the maintainer's request for this PR, or state that the change is limited to i18n/localization.
|
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.
|
||||||
|
-->
|
||||||
|
|
||||||
## Checklist
|
**Before submitting, make sure you've checked the following:**
|
||||||
|
|
||||||
- [ ] I have read and I understand the [contribution policy](https://docs.openwebui.com/contributing/#submit-code).
|
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||||
- [ ] This PR targets the `dev` branch.
|
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||||
- [ ] This PR links to a well-described, confirmed Issue or active Discussion: `Closes #___` / `Relates to #___`.
|
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||||
- [ ] A maintainer explicitly asked me to open this PR, or this PR only updates i18n/localization.
|
- [ ] **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.
|
||||||
- [ ] The change is one logical unit with no unrelated commits.
|
- [ ] **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.
|
||||||
- [ ] I matched nearby code patterns and avoided unnecessary new settings, abstractions, or dependencies.
|
- [ ] **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**.
|
||||||
- [ ] I manually tested the changed workflow and any nearby behavior that could be affected.
|
- [ ] **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.
|
||||||
- [ ] I have not added or rewritten automated tests, fixtures, snapshots, or testing infrastructure unless a maintainer explicitly requested them.
|
- [ ] **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?
|
||||||
- [ ] I updated relevant docs, including the [Open WebUI Docs Repository](https://github.com/open-webui/docs), if needed.
|
- [ ] **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.
|
||||||
- [ ] I added screenshots for UI changes, and a recording when motion or interaction matters.
|
- [ ] **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.
|
||||||
- [ ] I reviewed any AI-generated code before submitting it.
|
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||||
- [ ] The PR title uses one of the prefixes listed below.
|
- **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 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
|
||||||
|
|
||||||
## Title Prefix
|
# Changelog Entry
|
||||||
|
|
||||||
Use one of the following prefixes:
|
### Description
|
||||||
|
|
||||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
- [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)]
|
||||||
- **build**: Build system or dependency changes
|
|
||||||
- **ci**: CI/CD workflow changes
|
|
||||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
|
||||||
- **docs**: Documentation additions or updates
|
|
||||||
- **feat**: New features or enhancements
|
|
||||||
- **fix**: Bug fixes or corrections
|
|
||||||
- **i18n**: Internationalization or localization changes
|
|
||||||
- **perf**: Performance improvements
|
|
||||||
- **refactor**: Code restructuring
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Describe the change, the problem it solves, and the impact on users.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Describe how you reproduced the problem and manually checked the behavior before and after the change. Include exact steps, setup details, and relevant logs, screenshots, or recordings. Report results from relevant existing checks and anything you could not verify.
|
|
||||||
|
|
||||||
Do not add or rewrite automated tests unless a maintainer explicitly requests them. Tests that repeat an implementation's assumptions can pass while preserving the same mistake; maintainers determine the regression coverage needed. Do not remove, disable, or weaken existing tests to make the change pass.
|
|
||||||
|
|
||||||
## Changelog Entry
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
-
|
- [List any new features, functionalities, or additions]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
-
|
- [List any changes, updates, refactorings, or optimizations]
|
||||||
|
|
||||||
### Fixed
|
### Deprecated
|
||||||
|
|
||||||
-
|
- [List any deprecated functionality or features that have been removed]
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
-
|
- [List any removed features, files, or functionalities]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- [List any fixes, corrections, or bug fixes]
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
-
|
- [List any new or updated security-related changes, including vulnerability fixes]
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
-
|
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
|
||||||
|
|
||||||
## Additional Context
|
---
|
||||||
|
|
||||||
Add anything maintainers should know before review.
|
### Additional Information
|
||||||
|
|
||||||
## Contributor License Agreement
|
- [Insert any additional context, notes, or explanations for the changes]
|
||||||
|
- [Reference any related issues, commits, or other relevant information]
|
||||||
|
|
||||||
|
### Screenshots or Videos
|
||||||
|
|
||||||
|
- [Attach any relevant screenshots or videos demonstrating the changes]
|
||||||
|
|
||||||
|
### Contributor License Agreement
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
DO NOT DELETE THIS SECTION.
|
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||||
Your PR will not be reviewed or merged until you check the box below confirming that you have read and agree to the CLA.
|
Keep the "Contributor License Agreement" confirmation text intact.
|
||||||
|
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||||
|
|
||||||
|
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||||
|
|
|
||||||
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 }}"
|
||||||
417
.github/workflows/docker.yaml
vendored
417
.github/workflows/docker.yaml
vendored
|
|
@ -1,417 +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]
|
|
||||||
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}"
|
|
||||||
for ATTEMPT in 1 2 3; do
|
|
||||||
if docker buildx imagetools create -t "${DEST}" "${SOURCE}" && \
|
|
||||||
docker buildx imagetools inspect "${DEST}"; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "${ATTEMPT}" = "3" ]; then
|
|
||||||
echo "Failed to copy ${DEST} after ${ATTEMPT} attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Copy attempt ${ATTEMPT} for ${DEST} failed, retrying in 15s..."
|
|
||||||
sleep 15
|
|
||||||
done
|
|
||||||
done <<< "${{ steps.tags.outputs.tags }}"
|
|
||||||
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']
|
|
||||||
});
|
|
||||||
45
.github/workflows/regression.yaml
vendored
45
.github/workflows/regression.yaml
vendored
|
|
@ -1,45 +0,0 @@
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Tests — run the open-webui/tests unit suite against a release candidate
|
|
||||||
# Release pull requests go from dev into main and are titled with the version
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
name: Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main, dev]
|
|
||||||
types: [opened, synchronize, reopened, edited]
|
|
||||||
|
|
||||||
# An edit must not cancel a running suite: the replacement run would skip it and still report green.
|
|
||||||
concurrency:
|
|
||||||
group: regression-${{ github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event.action != 'edited' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Into main only the release branch counts; into dev any version title does, so the
|
|
||||||
# suite can be exercised outside a release. Expressions have no regex, hence the literal prefixes.
|
|
||||||
regression:
|
|
||||||
name: Suite
|
|
||||||
if: >-
|
|
||||||
(github.event.pull_request.base.ref == 'dev' ||
|
|
||||||
github.event.pull_request.head.ref == 'dev') &&
|
|
||||||
(github.event.action != 'edited' || github.event.changes.title != null) &&
|
|
||||||
(startsWith(github.event.pull_request.title, '0.') ||
|
|
||||||
startsWith(github.event.pull_request.title, '1.'))
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
uses: open-webui/tests/.github/workflows/regression.yml@main
|
|
||||||
with:
|
|
||||||
open-webui-ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
|
|
||||||
# Single check to require in branch protection.
|
|
||||||
result:
|
|
||||||
name: Result
|
|
||||||
needs: [regression]
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions: {}
|
|
||||||
steps:
|
|
||||||
- name: Fail unless the suite passed or was not required
|
|
||||||
if: needs.regression.result != 'success' && needs.regression.result != 'skipped'
|
|
||||||
run: exit 1
|
|
||||||
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/videos
|
||||||
cypress/screenshots
|
cypress/screenshots
|
||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
.cptr
|
|
||||||
|
|
|
||||||
1356
CHANGELOG.md
1356
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.
|
- **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.
|
- **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.
|
- **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:
|
Examples of unacceptable behavior include:
|
||||||
|
|
@ -32,23 +32,11 @@ 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.
|
- **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.
|
- **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.
|
- **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.
|
|
||||||
|
|
||||||
### How We Develop the Project
|
|
||||||
|
|
||||||
Development is led by the maintainers, and code pull requests are reserved for work we explicitly request or exceptional contributions we choose to consider at our discretion. We use actionable reports and concrete use cases to understand problems, then evaluate, revise, and implement the appropriate approach internally. We assess each change against the project's architecture, existing behavior, quality standards, and future direction before settling on an implementation. Resolving a reported problem requires that broader context, and a working external patch usually requires substantial rewriting to meet the project's standards. Reviewing the patch, explaining the required changes, and coordinating successive revisions usually takes more effort than developing the solution internally. Fragmented commit histories, branches that have not been rebased, unresolved conflicts, and lengthy or unverified AI-generated comments add cleanup and discussion that delay the underlying work. Maintainers remain responsible for testing, documenting, supporting, and maintaining every accepted change, so we choose the approach based on the whole product and its ongoing maintenance. Clear reports, reproduction details, and relevant context give us what we need to make those decisions and develop the solution. A polished implementation, clean commit history, or completed checklist does not establish an exception to this process, and opening an issue or discussion is not an invitation to submit a PR. Wait for an explicit maintainer request before investing in a PR; unsolicited submissions are generally closed without review, and requested PRs remain subject to maintainer judgment.
|
|
||||||
|
|
||||||
### Feedback and Community Engagement
|
### Feedback and Community Engagement
|
||||||
|
|
||||||
Participation should help maintainers understand a concrete problem while respecting the project's priorities and available capacity. Please follow the [issue templates](.github/ISSUE_TEMPLATE) and [pull request policy](.github/pull_request_template.md) before submitting anything.
|
- **Constructive feedback is encouraged, but hostile or entitled behavior will result in immediate action.** If you disagree with elements of the project, we encourage you to offer meaningful improvements or fork the project if necessary. Healthy discussions and technical disagreements are welcome only when handled with professionalism.
|
||||||
|
- **Respect contributors' time and efforts.** No one is entitled to personalized or on-demand assistance. This is a community built on collaboration and shared effort; demanding or demeaning behavior undermines that trust and will not be allowed.
|
||||||
- **Make reports actionable.** Search existing issues and discussions, check the latest version and whether the problem is already addressed on `dev`, and use the appropriate template. Bug reports should describe a reproducible problem, the affected workflow, expected and actual behavior, and relevant evidence. Feature requests should explain the user-facing need; broader product, UX, architecture, or maintenance questions belong in Discussions. Report security concerns privately through the [security reporting process](https://github.com/open-webui/open-webui/security).
|
|
||||||
- **Share the problem before investing in code.** Start with an actionable issue or discussion and leave implementation planning to the maintainers. An issue or discussion alone is not an invitation to submit a PR. Please wait for an explicit request before opening one; any exception is at the maintainers' discretion. Implementation notes, local diffs, or patches may be shared as reference in the relevant issue or discussion.
|
|
||||||
- **Respect maintainers' discretion.** Submitting an issue, proposal, or pull request does not create an obligation to respond, review, implement, or merge it. Maintainers set the project's direction and defer or close submissions based on scope, quality, maintenance cost, or available capacity. Unsolicited pull requests are generally closed without review.
|
|
||||||
- **Keep discussion focused and concise.** Provide new information when it helps evaluate the problem. Repeated bumps, duplicate submissions, unsolicited direct messages seeking attention, or pressure for timelines place an unnecessary burden on contributors.
|
|
||||||
- **Respect decisions and boundaries.** Technical disagreement is welcome when expressed professionally. Reopening a declined request or continuing to press for a different outcome without new, relevant information is not constructive. You are free to explore a different direction in your own fork.
|
|
||||||
|
|
||||||
Participants are expected to respect maintainers' decisions and the contribution process. Harassment, hostility, or repeated disregard for these boundaries will result in enforcement under this Code of Conduct.
|
|
||||||
|
|
||||||
### Zero Tolerance: No Warnings, Immediate Action
|
### Zero Tolerance: No Warnings, Immediate Action
|
||||||
|
|
||||||
|
|
|
||||||
55
Dockerfile
55
Dockerfile
|
|
@ -26,9 +26,6 @@ ARG GID=0
|
||||||
######## WebUI frontend ########
|
######## WebUI frontend ########
|
||||||
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
|
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
|
||||||
ARG BUILD_HASH
|
ARG BUILD_HASH
|
||||||
ARG USE_SLIM
|
|
||||||
ARG UID
|
|
||||||
ARG GID
|
|
||||||
|
|
||||||
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
|
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
|
||||||
# ENV NODE_OPTIONS="--max-old-space-size=4096"
|
# ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||||
|
|
@ -43,17 +40,10 @@ RUN npm ci --force
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV APP_BUILD_HASH=${BUILD_HASH}
|
ENV APP_BUILD_HASH=${BUILD_HASH}
|
||||||
RUN npm run build && \
|
RUN npm run build
|
||||||
if [ "$USE_SLIM" = "true" ]; then find build -type f -name '*.map' -delete; fi
|
|
||||||
|
|
||||||
# Prepare backend ownership before the final copy so static assets occupy one layer.
|
|
||||||
# Group 0 write access lets arbitrary OpenShift UIDs update these assets at startup.
|
|
||||||
RUN chown -R $UID:$GID /app/backend && \
|
|
||||||
chgrp -R 0 /app/backend/open_webui/static && \
|
|
||||||
chmod -R g=u /app/backend/open_webui/static
|
|
||||||
|
|
||||||
######## WebUI backend ########
|
######## WebUI backend ########
|
||||||
FROM python:3.11-slim-bookworm AS base
|
FROM python:3.11.14-slim-bookworm AS base
|
||||||
|
|
||||||
# Use args
|
# Use args
|
||||||
ARG USE_CUDA
|
ARG USE_CUDA
|
||||||
|
|
@ -133,33 +123,24 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry
|
||||||
# Make sure the user has access to the app and root directory
|
# Make sure the user has access to the app and root directory
|
||||||
RUN chown -R $UID:$GID /app $HOME
|
RUN chown -R $UID:$GID /app $HOME
|
||||||
|
|
||||||
# Slim cannot bundle a local model server or GPU runtime.
|
# Install common system dependencies
|
||||||
RUN if [ "$USE_SLIM" = "true" ] && { [ "$USE_CUDA" = "true" ] || [ "$USE_OLLAMA" = "true" ]; }; then \
|
|
||||||
echo "USE_SLIM cannot be combined with USE_CUDA or USE_OLLAMA" >&2; exit 1; fi
|
|
||||||
|
|
||||||
# Keep the slim runtime free of local document/audio processing tools.
|
|
||||||
# Git-based tool requirements require the standard image.
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
curl jq ca-certificates \
|
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||||
&& if [ "$USE_SLIM" != "true" ]; then \
|
libmariadb-dev \
|
||||||
apt-get install -y --no-install-recommends \
|
python3-dev \
|
||||||
git build-essential pandoc gcc libmariadb-dev ffmpeg libsm6 libxext6; \
|
ffmpeg libsm6 libxext6 zstd \
|
||||||
fi && if [ "$USE_OLLAMA" = "true" ]; then \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
apt-get install -y --no-install-recommends zstd; \
|
|
||||||
fi && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# install python dependencies
|
# install python dependencies
|
||||||
COPY --chown=$UID:$GID ./backend/requirements*.txt ./
|
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
|
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
|
||||||
ENV UV_LINK_MODE=copy
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.12.10,source=/uv,target=/bin/uv \
|
RUN set -e; \
|
||||||
set -e; \
|
pip3 install --no-cache-dir uv; \
|
||||||
if [ "$USE_SLIM" = "true" ]; then \
|
if [ "$USE_CUDA" = "true" ]; then \
|
||||||
uv pip install --system -r requirements-slim.txt --no-cache-dir; \
|
|
||||||
elif [ "$USE_CUDA" = "true" ]; then \
|
|
||||||
# If you use CUDA the whisper and embedding model will be downloaded on first use
|
# If you use CUDA the whisper and embedding model will be downloaded on first use
|
||||||
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
|
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
|
||||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir; \
|
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir; \
|
||||||
|
|
@ -168,6 +149,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.12.10,source=/uv,target=/bin/uv \
|
||||||
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 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; 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 os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||||
|
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||||
else \
|
else \
|
||||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir; \
|
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; \
|
uv pip install --system -r requirements.txt --no-cache-dir; \
|
||||||
|
|
@ -176,17 +158,12 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.12.10,source=/uv,target=/bin/uv \
|
||||||
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 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; 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 os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||||
|
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||||
fi; \
|
fi; \
|
||||||
fi; \
|
fi; \
|
||||||
mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \
|
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/*;
|
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
|
# Install Ollama if requested
|
||||||
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
||||||
date +%s > /tmp/ollama_build_hash && \
|
date +%s > /tmp/ollama_build_hash && \
|
||||||
|
|
@ -204,8 +181,8 @@ COPY --chown=$UID:$GID --from=build /app/build /app/build
|
||||||
COPY --chown=$UID:$GID --from=build /app/CHANGELOG.md /app/CHANGELOG.md
|
COPY --chown=$UID:$GID --from=build /app/CHANGELOG.md /app/CHANGELOG.md
|
||||||
COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
|
COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
|
||||||
|
|
||||||
# copy backend files with the ownership and static permissions prepared above
|
# copy backend files
|
||||||
COPY --from=build /app/backend .
|
COPY --chown=$UID:$GID ./backend .
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|
|
||||||
88
README.md
88
README.md
|
|
@ -8,9 +8,11 @@
|
||||||

|

|
||||||

|

|
||||||
[](https://discord.gg/5rJgQTnV4s)
|
[](https://discord.gg/5rJgQTnV4s)
|
||||||
[](https://github.com/sponsors/open-webui)
|
[](https://github.com/sponsors/tjbck)
|
||||||
|
|
||||||
Open WebUI is **a home for AI**, a self-hosted AI platform that's **[extensible](https://docs.openwebui.com/features/extensibility/plugin/)**, **[feature-rich](https://docs.openwebui.com/features/)**, user-friendly, and built to run **[entirely offline](https://openwebui.com/sovereign-ai)**. With support for **Ollama** and **OpenAI-compatible APIs**, it gives you a powerful, provider-agnostic interface for both local and cloud-based models.
|

|
||||||
|
|
||||||
|
**Open WebUI is an [extensible](https://docs.openwebui.com/features/extensibility/plugin), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
|
||||||
|
|
||||||
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
|
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
|
||||||
|
|
||||||
|
|
@ -18,89 +20,65 @@ Passionate about open-source AI? [Join our team →](https://careers.openwebui.c
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)**
|
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)**
|
||||||
|
>
|
||||||
|
> Get **enhanced capabilities**, including **custom theming and branding**, **Service Level Agreement (SLA) support**, **Long-Term Support (LTS) versions**, and **more!**
|
||||||
|
|
||||||
For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
|
For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
|
||||||
|
|
||||||
## Key Features of Open WebUI ⭐
|
## 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.
|
||||||
|
|
||||||
- ⚡ **Agentic Execution with [Open Terminal](https://github.com/open-webui/open-terminal)**: Give your agents a terminal and filesystem to carry out multi-step tasks. Let them analyze data, run scripts, fix errors, and produce files directly in chat. Scale to teams with **[Terminals (Enterprise)](https://github.com/open-webui/terminals)** for per-user isolated environments, resource limits, and automatic lifecycle management.
|
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||||
|
|
||||||
- 📝 **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.
|
- 🎤📹 **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.
|
||||||
|
|
||||||
- 📢 **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.
|
- 🛠️ **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.
|
||||||
|
|
||||||
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
|
- 🐍 **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.
|
||||||
|
|
||||||
- ✅ **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.
|
- 💾 **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.
|
||||||
|
|
||||||
- 📅 **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.
|
- 📚 **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, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||||
|
|
||||||
- ⏱️ **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.
|
- 🔍 **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.
|
||||||
|
|
||||||
- 📱 **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 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.
|
||||||
|
|
||||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
- 🎨 **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.
|
||||||
|
|
||||||
- 🎤📹 **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).
|
- ⚙️ **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.
|
||||||
|
|
||||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
|
- 🔐 **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.
|
||||||
|
|
||||||
- 📚 **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.
|
- 🗄️ **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 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.
|
- 🔍 **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.
|
||||||
|
|
||||||
- 🌐 **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.
|
- 🔐 **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.
|
||||||
|
|
||||||
- 🎨 **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.
|
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
|
||||||
|
|
||||||
- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
|
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
|
||||||
|
|
||||||
- 📊 **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.
|
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
|
||||||
|
|
||||||
- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
|
- 🌐🌍 **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!
|
||||||
|
|
||||||
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
|
- 🧩 **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.
|
||||||
|
|
||||||
- 🪪 **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.
|
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
|
||||||
|
|
||||||
- ☁️ **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).
|
|
||||||
|
|
||||||
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
|
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!
|
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!
|
||||||
|
|
@ -244,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
|
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! 🤝
|
[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
|
## Star History
|
||||||
|
|
||||||
<a href="https://star-history.com/#open-webui/open-webui&Date">
|
<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"
|
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
|
||||||
PORT="${PORT:-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
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,12 @@ import uvicorn
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
|
|
||||||
KEY_FILE = Path.cwd() / '.webui_secret_key'
|
KEY_FILE = Path.cwd() / '.webui_secret_key'
|
||||||
DEFAULT_SECRET_KEY_LENGTH = 24
|
|
||||||
|
|
||||||
|
|
||||||
def version_callback(value: bool) -> None:
|
def version_callback(value: bool) -> None:
|
||||||
if value:
|
if value:
|
||||||
from open_webui.env import VERSION
|
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}')
|
typer.echo(f'Open WebUI version: {VERSION}')
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
@ -41,11 +37,8 @@ def serve(
|
||||||
if os.getenv('WEBUI_SECRET_KEY') is None:
|
if os.getenv('WEBUI_SECRET_KEY') is None:
|
||||||
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
|
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
|
||||||
if not KEY_FILE.exists():
|
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}')
|
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}')
|
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
|
||||||
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
|
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
|
||||||
|
|
||||||
|
|
@ -74,7 +67,7 @@ def serve(
|
||||||
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
|
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
|
||||||
|
|
||||||
import open_webui.main # noqa: F401
|
import open_webui.main # noqa: F401
|
||||||
from open_webui.env import UVICORN_WORKERS, UVICORN_WS_PER_MESSAGE_DEFLATE
|
from open_webui.env import UVICORN_WORKERS # Import the workers setting
|
||||||
|
|
||||||
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
|
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
|
||||||
# which is incompatible with psycopg v3 async. Setting loop='none' lets
|
# which is incompatible with psycopg v3 async. Setting loop='none' lets
|
||||||
|
|
@ -87,7 +80,6 @@ def serve(
|
||||||
port=port,
|
port=port,
|
||||||
forwarded_allow_ips='*',
|
forwarded_allow_ips='*',
|
||||||
workers=UVICORN_WORKERS,
|
workers=UVICORN_WORKERS,
|
||||||
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
|
|
||||||
loop=loop,
|
loop=loop,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -98,15 +90,12 @@ def dev(
|
||||||
port: int = 8080,
|
port: int = 8080,
|
||||||
reload: bool = True,
|
reload: bool = True,
|
||||||
):
|
):
|
||||||
from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE
|
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
'open_webui.main:app',
|
'open_webui.main:app',
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
reload=reload,
|
reload=reload,
|
||||||
forwarded_allow_ips='*',
|
forwarded_allow_ips='*',
|
||||||
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +1,5 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import errno
|
|
||||||
from enum import Enum
|
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):
|
class MESSAGES(str, Enum):
|
||||||
DEFAULT = lambda msg='': f'{msg if msg else ""}'
|
DEFAULT = lambda msg='': f'{msg if msg else ""}'
|
||||||
|
|
@ -38,7 +16,7 @@ class ERROR_MESSAGES(str, Enum):
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return super().__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.'
|
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.'
|
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.'
|
DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.'
|
||||||
|
|
@ -94,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.'
|
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.'
|
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
|
||||||
|
|
||||||
|
|
@ -117,16 +95,9 @@ class ERROR_MESSAGES(str, Enum):
|
||||||
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
|
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
|
||||||
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
|
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
|
||||||
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
|
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'
|
|
||||||
)
|
|
||||||
CALENDAR_RRULE_TOO_FREQUENT = 'Recurring events cannot repeat more often than daily'
|
|
||||||
|
|
||||||
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
|
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
|
||||||
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
|
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'
|
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
|
||||||
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
|
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
|
||||||
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"
|
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 logging
|
||||||
import sys
|
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 (
|
from fastapi import (
|
||||||
Depends,
|
Depends,
|
||||||
FastAPI,
|
FastAPI,
|
||||||
|
|
@ -14,36 +16,40 @@ from fastapi import (
|
||||||
UploadFile,
|
UploadFile,
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel
|
|
||||||
from starlette.responses import Response, StreamingResponse
|
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.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 (
|
from open_webui.socket.main import (
|
||||||
get_event_call,
|
get_event_call,
|
||||||
get_event_emitter,
|
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.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 (
|
from open_webui.utils.misc import (
|
||||||
add_or_update_system_message,
|
add_or_update_system_message,
|
||||||
get_last_user_message,
|
get_last_user_message,
|
||||||
|
prepend_to_first_user_message_content,
|
||||||
openai_chat_chunk_message_template,
|
openai_chat_chunk_message_template,
|
||||||
openai_chat_completion_message_template,
|
openai_chat_completion_message_template,
|
||||||
prepend_to_first_user_message_content,
|
|
||||||
)
|
)
|
||||||
from open_webui.utils.payload import (
|
from open_webui.utils.payload import (
|
||||||
apply_model_params_to_body_openai,
|
apply_model_params_to_body_openai,
|
||||||
apply_system_prompt_to_body,
|
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)
|
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||||
log = logging.getLogger(__name__)
|
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):
|
async def get_function_models(request):
|
||||||
if not ENABLE_PLUGINS:
|
|
||||||
return []
|
|
||||||
|
|
||||||
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
|
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
|
||||||
pipe_models = []
|
pipe_models = []
|
||||||
|
|
||||||
|
|
@ -100,7 +103,7 @@ async def get_function_models(request):
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
sub_pipes = []
|
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:
|
for p in sub_pipes:
|
||||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||||
|
|
@ -126,10 +129,7 @@ async def get_function_models(request):
|
||||||
pipe_flag = {'type': 'pipe'}
|
pipe_flag = {'type': 'pipe'}
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"get_function_models: function '%s' is a single pipe { 'id': %s, 'name': %s }",
|
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||||
pipe.id,
|
|
||||||
pipe.id,
|
|
||||||
pipe.name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
pipe_models.append(
|
pipe_models.append(
|
||||||
|
|
@ -150,10 +150,7 @@ async def get_function_models(request):
|
||||||
return pipe_models
|
return pipe_models
|
||||||
|
|
||||||
|
|
||||||
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
|
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
|
||||||
if models is None:
|
|
||||||
models = {}
|
|
||||||
|
|
||||||
async def execute_pipe(pipe, params):
|
async def execute_pipe(pipe, params):
|
||||||
if inspect.iscoroutinefunction(pipe):
|
if inspect.iscoroutinefunction(pipe):
|
||||||
return await pipe(**params)
|
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 = line.model_dump_json()
|
||||||
line = f'data: {line}'
|
line = f'data: {line}'
|
||||||
if isinstance(line, dict):
|
if isinstance(line, dict):
|
||||||
line = f'data: {JSONCodec.dumps(line)}'
|
line = f'data: {json.dumps(line)}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
line = line.decode('utf-8')
|
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'
|
return f'{line}\n\n'
|
||||||
else:
|
else:
|
||||||
line = openai_chat_chunk_message_template(form_data['model'], line)
|
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:
|
def get_pipe_id(form_data: dict) -> str:
|
||||||
pipe_id = form_data['model']
|
pipe_id = form_data['model']
|
||||||
|
|
@ -212,13 +209,6 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
||||||
|
|
||||||
return params
|
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_id = form_data.get('model')
|
||||||
model_info = await Models.get_model_by_id(model_id)
|
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:
|
if params:
|
||||||
system = params.pop('system', None)
|
system = params.pop('system', None)
|
||||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
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 = await apply_system_prompt_to_body(system, form_data, metadata, user)
|
|
||||||
|
|
||||||
pipe_id = get_pipe_id(form_data)
|
pipe_id = get_pipe_id(form_data)
|
||||||
function_module = await get_function_module_by_id(request, pipe_id)
|
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
|
yield data
|
||||||
return
|
return
|
||||||
if isinstance(res, dict):
|
if isinstance(res, dict):
|
||||||
yield f'data: {JSONCodec.dumps(res)}\n\n'
|
yield f'data: {json.dumps(res)}\n\n'
|
||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f'Error: {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
|
return
|
||||||
|
|
||||||
if isinstance(res, str):
|
if isinstance(res, str):
|
||||||
message = openai_chat_chunk_message_template(form_data['model'], res)
|
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):
|
if isinstance(res, Iterator):
|
||||||
for line in res:
|
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:
|
async for line in res:
|
||||||
yield process_line(form_data, line)
|
yield process_line(form_data, line)
|
||||||
|
|
||||||
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
|
if isinstance(res, str) or isinstance(res, Generator):
|
||||||
finish_message['choices'][0]['finish_reason'] = 'stop'
|
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
|
||||||
yield f'data: {JSONCodec.dumps(finish_message)}\n\n'
|
finish_message['choices'][0]['finish_reason'] = 'stop'
|
||||||
yield 'data: [DONE]'
|
yield f'data: {json.dumps(finish_message)}\n\n'
|
||||||
|
yield 'data: [DONE]'
|
||||||
|
|
||||||
return StreamingResponse(stream_content(), media_type='text/event-stream')
|
return StreamingResponse(stream_content(), media_type='text/event-stream')
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,36 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager, contextmanager
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
from open_webui.internal.wrappers import register_connection
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
DATABASE_ENABLE_IAM_TOKEN_AUTH,
|
OPEN_WEBUI_DIR,
|
||||||
DATABASE_ENABLE_SESSION_SHARING,
|
DATABASE_URL,
|
||||||
DATABASE_ENABLE_SQLITE_WAL,
|
DATABASE_SCHEMA,
|
||||||
DATABASE_POOL_MAX_OVERFLOW,
|
DATABASE_POOL_MAX_OVERFLOW,
|
||||||
DATABASE_POOL_RECYCLE,
|
DATABASE_POOL_RECYCLE,
|
||||||
DATABASE_POOL_SIZE,
|
DATABASE_POOL_SIZE,
|
||||||
DATABASE_POOL_TIMEOUT,
|
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_BUSY_TIMEOUT,
|
||||||
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
|
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_SQLITE_PRAGMA_TEMP_STORE,
|
||||||
DATABASE_URL,
|
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
|
||||||
|
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
|
||||||
ENABLE_DB_MIGRATIONS,
|
ENABLE_DB_MIGRATIONS,
|
||||||
OPEN_WEBUI_DIR,
|
|
||||||
USE_SLIM,
|
|
||||||
)
|
)
|
||||||
from open_webui.utils.json_codec import JSONCodec
|
from peewee_migrate import Router
|
||||||
from sqlalchemy import Dialect, MetaData, create_engine, event, types
|
from sqlalchemy import Dialect, create_engine, MetaData, event, types
|
||||||
from sqlalchemy.engine.url import make_url
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
from sqlalchemy.orm import scoped_session, sessionmaker, Session
|
||||||
from sqlalchemy.pool import NullPool, QueuePool
|
from sqlalchemy.pool import QueuePool, NullPool
|
||||||
from sqlalchemy.sql.type_api import _T
|
from sqlalchemy.sql.type_api import _T
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
|
@ -122,36 +117,61 @@ extract_ssl_mode_from_url = extract_ssl_params_from_url
|
||||||
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
|
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
|
||||||
|
|
||||||
|
|
||||||
class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
|
class JSONField(types.TypeDecorator):
|
||||||
"""Store arbitrary Python objects as JSON-encoded TEXT.
|
impl = types.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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
impl = types.UnicodeText
|
|
||||||
cache_ok = True
|
cache_ok = True
|
||||||
|
|
||||||
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
|
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||||
return JSONCodec.dumps(value) if value is not None else None
|
return json.dumps(value)
|
||||||
|
|
||||||
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
|
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||||
return JSONCodec.loads(value) if value is not None else None
|
if value is not None:
|
||||||
|
return json.loads(value)
|
||||||
|
|
||||||
def copy(self, **kwargs: Any) -> Self:
|
def copy(self, **kw: Any) -> Self:
|
||||||
return JSONField(length=self.impl.length)
|
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)
|
||||||
|
|
||||||
|
|
||||||
if USE_SLIM:
|
# Workaround to handle the peewee migration
|
||||||
if make_url(DATABASE_URL).get_backend_name() not in ('sqlite', 'postgresql', 'postgres'):
|
# This is required to ensure the peewee migration is handled before the alembic migration
|
||||||
raise ValueError(
|
def handle_peewee_migration(DATABASE_URL):
|
||||||
'Slim requires SQLite or PostgreSQL for DATABASE_URL. Use the standard image for other databases.'
|
db = None
|
||||||
)
|
try:
|
||||||
if DATABASE_ENABLE_IAM_TOKEN_AUTH:
|
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`)
|
||||||
raise ValueError(
|
# and cert-file params are preserved in the connection string.
|
||||||
'AWS RDS IAM authentication requires the standard image. Slim supports PostgreSQL database credentials.'
|
url_without_ssl, ssl_params = extract_ssl_params_from_url(DATABASE_URL)
|
||||||
)
|
normalized_url = reattach_ssl_params_to_url(url_without_ssl, ssl_params)
|
||||||
|
|
||||||
|
# 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.'
|
||||||
|
|
||||||
|
|
||||||
|
if ENABLE_DB_MIGRATIONS:
|
||||||
|
handle_peewee_migration(DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
# Normalize SSL params from the URL once; the sync engine needs them
|
# Normalize SSL params from the URL once; the sync engine needs them
|
||||||
|
|
@ -162,77 +182,6 @@ _url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
|
||||||
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
|
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
_rds_iam_token_auth = RDSIAMTokenAuth(SQLALCHEMY_DATABASE_URL) if DATABASE_ENABLE_IAM_TOKEN_AUTH else None
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_async_url(url: str) -> str:
|
def _make_async_url(url: str) -> str:
|
||||||
"""Convert a sync database URL to its async driver equivalent.
|
"""Convert a sync database URL to its async driver equivalent.
|
||||||
|
|
||||||
|
|
@ -259,27 +208,6 @@ def _make_async_url(url: str) -> str:
|
||||||
return url
|
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,
|
# SYNC ENGINE (used only for: startup migrations, config loading,
|
||||||
# Alembic, peewee migration, health checks)
|
# Alembic, peewee migration, health checks)
|
||||||
|
|
@ -308,7 +236,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
||||||
# in the native sqlcipher3 C library. Use NullPool by default for safety,
|
# in the native sqlcipher3 C library. Use NullPool by default for safety,
|
||||||
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
|
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
|
||||||
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
|
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
|
||||||
engine = _create_engine(
|
engine = create_engine(
|
||||||
'sqlite://',
|
'sqlite://',
|
||||||
creator=create_sqlcipher_connection,
|
creator=create_sqlcipher_connection,
|
||||||
pool_size=DATABASE_POOL_SIZE,
|
pool_size=DATABASE_POOL_SIZE,
|
||||||
|
|
@ -320,7 +248,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
||||||
echo=False,
|
echo=False,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
engine = _create_engine(
|
engine = create_engine(
|
||||||
'sqlite://',
|
'sqlite://',
|
||||||
creator=create_sqlcipher_connection,
|
creator=create_sqlcipher_connection,
|
||||||
poolclass=NullPool,
|
poolclass=NullPool,
|
||||||
|
|
@ -330,49 +258,10 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
||||||
log.info('Connected to encrypted SQLite database using SQLCipher')
|
log.info('Connected to encrypted SQLite database using SQLCipher')
|
||||||
|
|
||||||
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
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):
|
def _apply_sqlite_pragmas(dbapi_connection):
|
||||||
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
|
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
|
||||||
# SQLite LIKE folds ASCII only; SQLAlchemy SQLite ILIKE compiles to lower(x) LIKE lower(?).
|
|
||||||
compiled_patterns = {}
|
|
||||||
|
|
||||||
def like(pattern, value, escape=None):
|
|
||||||
if pattern is None or value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
pattern = str(pattern).lower()
|
|
||||||
escape = str(escape).lower() if escape is not None else None
|
|
||||||
key = (pattern, escape)
|
|
||||||
compiled = compiled_patterns.get(key)
|
|
||||||
if compiled is False:
|
|
||||||
return False
|
|
||||||
if compiled is None:
|
|
||||||
regex = []
|
|
||||||
escaped = False
|
|
||||||
for char in pattern:
|
|
||||||
if escape and not escaped and char == escape:
|
|
||||||
escaped = True
|
|
||||||
continue
|
|
||||||
regex.append(
|
|
||||||
'.*' if not escaped and char == '%' else '.' if not escaped and char == '_' else re.escape(char)
|
|
||||||
)
|
|
||||||
escaped = False
|
|
||||||
if escaped:
|
|
||||||
compiled = False
|
|
||||||
if len(compiled_patterns) >= 512:
|
|
||||||
compiled_patterns.clear()
|
|
||||||
compiled_patterns[key] = compiled
|
|
||||||
return False
|
|
||||||
compiled = re.compile(''.join(regex), re.DOTALL)
|
|
||||||
if len(compiled_patterns) >= 512:
|
|
||||||
compiled_patterns.clear()
|
|
||||||
compiled_patterns[key] = compiled
|
|
||||||
|
|
||||||
return compiled.fullmatch(str(value).lower()) is not None
|
|
||||||
|
|
||||||
dbapi_connection.create_function('like', 2, like, deterministic=True)
|
|
||||||
dbapi_connection.create_function('like', 3, like, deterministic=True)
|
|
||||||
cursor = dbapi_connection.cursor()
|
cursor = dbapi_connection.cursor()
|
||||||
if DATABASE_ENABLE_SQLITE_WAL:
|
if DATABASE_ENABLE_SQLITE_WAL:
|
||||||
cursor.execute('PRAGMA journal_mode=WAL')
|
cursor.execute('PRAGMA journal_mode=WAL')
|
||||||
|
|
@ -401,7 +290,7 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
||||||
else:
|
else:
|
||||||
if isinstance(DATABASE_POOL_SIZE, int):
|
if isinstance(DATABASE_POOL_SIZE, int):
|
||||||
if DATABASE_POOL_SIZE > 0:
|
if DATABASE_POOL_SIZE > 0:
|
||||||
engine = _create_engine(
|
engine = create_engine(
|
||||||
SQLALCHEMY_DATABASE_URL,
|
SQLALCHEMY_DATABASE_URL,
|
||||||
pool_size=DATABASE_POOL_SIZE,
|
pool_size=DATABASE_POOL_SIZE,
|
||||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||||
|
|
@ -411,11 +300,9 @@ else:
|
||||||
poolclass=QueuePool,
|
poolclass=QueuePool,
|
||||||
)
|
)
|
||||||
else:
|
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:
|
else:
|
||||||
engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||||
|
|
||||||
enable_iam_token_auth(engine)
|
|
||||||
|
|
||||||
|
|
||||||
# Sync session — used ONLY for startup config loading (config.py runs at import time)
|
# Sync session — used ONLY for startup config loading (config.py runs at import time)
|
||||||
|
|
@ -457,15 +344,14 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
|
||||||
|
|
||||||
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||||
# Generous default — async coroutines + no session sharing = high connection demand.
|
# 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
|
_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,
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||||
connect_args={'check_same_thread': False},
|
connect_args={'check_same_thread': False},
|
||||||
pool_size=_sqlite_pool_size,
|
pool_size=_sqlite_pool_size,
|
||||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||||
|
pool_pre_ping=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@event.listens_for(async_engine.sync_engine, 'connect')
|
@event.listens_for(async_engine.sync_engine, 'connect')
|
||||||
|
|
@ -474,7 +360,7 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||||
else:
|
else:
|
||||||
if isinstance(DATABASE_POOL_SIZE, int):
|
if isinstance(DATABASE_POOL_SIZE, int):
|
||||||
if DATABASE_POOL_SIZE > 0:
|
if DATABASE_POOL_SIZE > 0:
|
||||||
async_engine = _create_async_engine(
|
async_engine = create_async_engine(
|
||||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||||
pool_size=DATABASE_POOL_SIZE,
|
pool_size=DATABASE_POOL_SIZE,
|
||||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||||
|
|
@ -483,19 +369,17 @@ else:
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
async_engine = _create_async_engine(
|
async_engine = create_async_engine(
|
||||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
poolclass=NullPool,
|
poolclass=NullPool,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
async_engine = _create_async_engine(
|
async_engine = create_async_engine(
|
||||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
enable_iam_token_auth(async_engine)
|
|
||||||
|
|
||||||
|
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
bind=async_engine,
|
bind=async_engine,
|
||||||
|
|
@ -526,7 +410,7 @@ async def get_async_db():
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@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."""
|
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
|
||||||
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
|
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
|
||||||
yield db
|
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 logging
|
||||||
import alembic.context
|
from logging.config import fileConfig
|
||||||
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 alembic import context
|
||||||
from open_webui.models.auths import Auth
|
from open_webui.models.auths import Auth
|
||||||
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
||||||
from open_webui.models.chat_messages import ChatMessage # noqa: F401
|
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
|
||||||
from open_webui.models.chats import Chat # noqa: F401
|
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||||
from sqlalchemy import create_engine, engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool, create_engine
|
||||||
|
|
||||||
alembic_config = alembic.context.config
|
# this is the Alembic Config object, which provides
|
||||||
if alembic_config.config_file_name:
|
# access to the values within the .ini file in use.
|
||||||
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
|
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':
|
if LOG_FORMAT == 'json':
|
||||||
from open_webui.env import JSONFormatter
|
from open_webui.env import JSONFormatter
|
||||||
|
|
||||||
for log_handler in logging.root.handlers:
|
for handler in logging.root.handlers:
|
||||||
log_handler.setFormatter(JSONFormatter())
|
handler.setFormatter(JSONFormatter())
|
||||||
migration_metadata = Auth.metadata
|
|
||||||
target_db_url = DATABASE_URL
|
# add your model's MetaData object here
|
||||||
base_url, ssl_query_params = extract_ssl_params_from_url(target_db_url)
|
# for 'autogenerate' support
|
||||||
if ssl_query_params:
|
# from myapp import mymodel
|
||||||
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
|
# target_metadata = mymodel.Base.metadata
|
||||||
if target_db_url:
|
target_metadata = Auth.metadata
|
||||||
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
|
|
||||||
|
# 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 for sync migrations).
|
||||||
|
url_without_ssl, ssl_params = extract_ssl_params_from_url(DB_URL)
|
||||||
|
DB_URL = reattach_ssl_params_to_url(url_without_ssl, ssl_params) if ssl_params else DB_URL
|
||||||
|
|
||||||
|
if DB_URL:
|
||||||
|
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
|
"""Run migrations in 'offline' mode.
|
||||||
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
|
|
||||||
alembic.context.configure(
|
This configures the context with just a URL
|
||||||
url=db_connection_url,
|
and not an Engine, though an Engine is acceptable
|
||||||
target_metadata=migration_metadata,
|
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,
|
literal_binds=True,
|
||||||
dialect_opts={'paramstyle': 'named'},
|
dialect_opts={'paramstyle': 'named'},
|
||||||
)
|
)
|
||||||
with alembic.context.begin_transaction():
|
|
||||||
alembic.context.run_migrations()
|
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
def _get_engine_connectable():
|
context.run_migrations()
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
def run_migrations_online() -> None:
|
||||||
"""Execute migrations against a live database connection."""
|
"""Run migrations in 'online' mode.
|
||||||
live_connectable = _get_engine_connectable()
|
|
||||||
enable_iam_token_auth(live_connectable)
|
In this scenario we need to create an Engine
|
||||||
with live_connectable.connect() as live_connection:
|
and associate a connection with the context.
|
||||||
alembic.context.configure(
|
|
||||||
connection=live_connection,
|
"""
|
||||||
target_metadata=migration_metadata,
|
# 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():
|
else:
|
||||||
alembic.context.run_migrations()
|
# 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 context.is_offline_mode():
|
||||||
if alembic.context.is_offline_mode():
|
run_migrations_offline()
|
||||||
run_migrations_offline() # run in offline mode
|
else:
|
||||||
if not alembic.context.is_offline_mode():
|
run_migrations_online()
|
||||||
run_migrations_online() # run in online mode
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
from __future__ import annotations
|
from alembic import op
|
||||||
|
from sqlalchemy import Inspector
|
||||||
"""Alembic migration utilities."""
|
|
||||||
|
|
||||||
from alembic import op # noqa: E402 — alembic runtime context
|
|
||||||
from sqlalchemy import inspect # metadata inspection
|
|
||||||
|
|
||||||
|
|
||||||
# --- database helper functions ---
|
def get_existing_tables():
|
||||||
def get_existing_tables() -> set[str]:
|
con = op.get_bind()
|
||||||
"""Return table names already present in the database."""
|
inspector = Inspector.from_engine(con)
|
||||||
conn = op.get_bind()
|
tables = set(inspector.get_table_names())
|
||||||
return set(inspect(conn).get_table_names())
|
return tables
|
||||||
|
|
||||||
|
|
||||||
def get_revision_id() -> str:
|
def get_revision_id():
|
||||||
"""Generate a short random revision identifier."""
|
|
||||||
import uuid
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision = '018012973d35'
|
revision = '018012973d35'
|
||||||
down_revision = 'd31026856c01'
|
down_revision = 'd31026856c01'
|
||||||
|
|
@ -16,34 +16,18 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
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
|
# Chat table indexes
|
||||||
if not _idx_exists('chat', 'folder_id_idx'):
|
op.create_index('folder_id_idx', 'chat', ['folder_id'])
|
||||||
op.create_index('folder_id_idx', 'chat', ['folder_id'])
|
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
|
||||||
if not _idx_exists('chat', 'user_id_pinned_idx'):
|
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
|
||||||
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
|
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
|
||||||
if not _idx_exists('chat', 'user_id_archived_idx'):
|
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
|
||||||
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'])
|
|
||||||
|
|
||||||
# Tag table index
|
# 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)
|
# Function table index
|
||||||
conn = op.get_bind()
|
op.create_index('is_global_idx', 'function', ['is_global'])
|
||||||
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'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.sql import table, select, update, column
|
||||||
from sqlalchemy.engine.reflection import Inspector
|
from sqlalchemy.engine.reflection import Inspector
|
||||||
from sqlalchemy.sql import column, select, table, update
|
|
||||||
|
import json
|
||||||
|
|
||||||
revision = '1af9b942657b'
|
revision = '1af9b942657b'
|
||||||
down_revision = '242a2047eae0'
|
down_revision = '242a2047eae0'
|
||||||
|
|
@ -93,11 +93,8 @@ def upgrade():
|
||||||
conn.execute(update_stmt)
|
conn.execute(update_stmt)
|
||||||
|
|
||||||
# Add columns `pinned` and `meta` to 'chat'
|
# Add columns `pinned` and `meta` to 'chat'
|
||||||
chat_columns = {c['name'] for c in inspector.get_columns('chat')}
|
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
|
||||||
if 'pinned' 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))
|
|
||||||
if 'meta' not in chat_columns:
|
|
||||||
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()))
|
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
|
||||||
chat = table(
|
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 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'
|
revision = '242a2047eae0'
|
||||||
down_revision = '6a39f3d8e55c'
|
down_revision = '6a39f3d8e55c'
|
||||||
|
|
@ -47,41 +47,34 @@ def upgrade():
|
||||||
# If the column is already JSON, no need to do anything
|
# If the column is already JSON, no need to do anything
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
|
# Step 3: Migrate data from 'old_chat' to 'chat'
|
||||||
# Re-check columns after potential rename above
|
chat_table = table(
|
||||||
current_cols = {c['name'] for c in sa.inspect(conn).get_columns('chat')}
|
'chat',
|
||||||
if 'old_chat' in current_cols:
|
sa.Column('id', sa.String(), primary_key=True),
|
||||||
chat_table = table(
|
sa.Column('old_chat', sa.Text()),
|
||||||
'chat',
|
sa.Column('chat', sa.JSON()),
|
||||||
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
|
# - Selecting all data from the table
|
||||||
connection = op.get_bind()
|
connection = op.get_bind()
|
||||||
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
||||||
for row in results:
|
for row in results:
|
||||||
try:
|
try:
|
||||||
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
||||||
json_data = json.loads(row.old_chat)
|
json_data = json.loads(row.old_chat)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
json_data = None # Handle cases where the text cannot be converted to JSON
|
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
|
# Step 4: Drop 'old_chat' column
|
||||||
print("Dropping 'old_chat' column")
|
print("Dropping 'old_chat' column")
|
||||||
op.drop_column('chat', 'old_chat')
|
op.drop_column('chat', 'old_chat')
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
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
|
# 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'
|
# Step 2: Convert 'chat' JSON data back to text and store in 'old_chat'
|
||||||
chat_table = table(
|
chat_table = table(
|
||||||
|
|
@ -91,14 +84,14 @@ def downgrade():
|
||||||
sa.Column('old_chat', sa.Text()),
|
sa.Column('old_chat', sa.Text()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'chat' in columns:
|
connection = op.get_bind()
|
||||||
results = conn.execute(select(chat_table.c.id, chat_table.c.chat))
|
results = connection.execute(select(chat_table.c.id, chat_table.c.chat))
|
||||||
for row in results:
|
for row in results:
|
||||||
text_data = json.dumps(row.chat) if row.chat is not None else None
|
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.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
|
# Step 3: Remove the new 'chat' JSON column
|
||||||
op.drop_column('chat', 'chat')
|
op.drop_column('chat', 'chat')
|
||||||
|
|
||||||
# Step 4: Rename 'old_chat' back to 'chat'
|
# Step 4: Rename 'old_chat' back to 'chat'
|
||||||
op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text())
|
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
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import open_webui.internal.db
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '2f1211949ecc'
|
revision: str = '2f1211949ecc'
|
||||||
|
|
@ -20,76 +20,63 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
|
|
||||||
# New columns to be added to channel_member table
|
# New columns to be added to channel_member table
|
||||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
|
||||||
if 'status' not in cm_cols:
|
op.add_column(
|
||||||
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
|
'channel_member',
|
||||||
if 'is_active' not in cm_cols:
|
sa.Column(
|
||||||
op.add_column(
|
'is_active',
|
||||||
'channel_member',
|
sa.Boolean(),
|
||||||
sa.Column(
|
nullable=False,
|
||||||
'is_active',
|
default=True,
|
||||||
sa.Boolean(),
|
server_default=sa.sql.expression.true(),
|
||||||
nullable=False,
|
),
|
||||||
default=True,
|
)
|
||||||
server_default=sa.sql.expression.true(),
|
|
||||||
),
|
op.add_column(
|
||||||
)
|
'channel_member',
|
||||||
if 'is_channel_muted' not in cm_cols:
|
sa.Column(
|
||||||
op.add_column(
|
'is_channel_muted',
|
||||||
'channel_member',
|
sa.Boolean(),
|
||||||
sa.Column(
|
nullable=False,
|
||||||
'is_channel_muted',
|
default=False,
|
||||||
sa.Boolean(),
|
server_default=sa.sql.expression.false(),
|
||||||
nullable=False,
|
),
|
||||||
default=False,
|
)
|
||||||
server_default=sa.sql.expression.false(),
|
op.add_column(
|
||||||
),
|
'channel_member',
|
||||||
)
|
sa.Column(
|
||||||
if 'is_channel_pinned' not in cm_cols:
|
'is_channel_pinned',
|
||||||
op.add_column(
|
sa.Boolean(),
|
||||||
'channel_member',
|
nullable=False,
|
||||||
sa.Column(
|
default=False,
|
||||||
'is_channel_pinned',
|
server_default=sa.sql.expression.false(),
|
||||||
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))
|
||||||
)
|
|
||||||
if 'data' not in cm_cols:
|
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
|
||||||
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
|
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
|
||||||
if 'meta' not in cm_cols:
|
|
||||||
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
|
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||||
if 'joined_at' not in cm_cols:
|
|
||||||
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
|
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||||
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))
|
|
||||||
|
|
||||||
# New columns to be added to message table
|
# New columns to be added to message table
|
||||||
msg_cols = {c['name'] for c in inspector.get_columns('message')}
|
op.add_column(
|
||||||
if 'is_pinned' not in msg_cols:
|
'message',
|
||||||
op.add_column(
|
sa.Column(
|
||||||
'message',
|
'is_pinned',
|
||||||
sa.Column(
|
sa.Boolean(),
|
||||||
'is_pinned',
|
nullable=False,
|
||||||
sa.Boolean(),
|
default=False,
|
||||||
nullable=False,
|
server_default=sa.sql.expression.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))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ Create Date: 2026-01-23 17:15:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
import uuid
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision: str = '374d2f66af06'
|
revision: str = '374d2f66af06'
|
||||||
down_revision: Union[str, None] = 'c440947495f3'
|
down_revision: Union[str, None] = 'c440947495f3'
|
||||||
|
|
@ -20,167 +20,150 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
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),
|
# Step 1: Read existing data from OLD table (schema likely command as PK)
|
||||||
# the migration completed successfully on a prior run — nothing to do.
|
# We use batch_alter previously, but we want to move to new table.
|
||||||
if 'prompt_history' in existing_tables and 'prompt_new' not in existing_tables:
|
# We need to assume the OLD structure.
|
||||||
# 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: command as PK)
|
old_prompt_table = sa.table(
|
||||||
# Only read if the old-schema prompt table still exists (has 'command' but no 'version_id')
|
'prompt',
|
||||||
existing_prompts = []
|
sa.column('command', sa.Text()),
|
||||||
if 'prompt' in existing_tables and 'prompt_new' not in existing_tables:
|
sa.column('user_id', sa.Text()),
|
||||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
sa.column('title', sa.Text()),
|
||||||
if 'command' in prompt_cols and 'version_id' not in prompt_cols:
|
sa.column('content', sa.Text()),
|
||||||
old_prompt_table = sa.table(
|
sa.column('timestamp', sa.BigInteger()),
|
||||||
'prompt',
|
sa.column('access_control', sa.JSON()),
|
||||||
sa.column('command', sa.Text()),
|
)
|
||||||
sa.column('user_id', sa.Text()),
|
|
||||||
sa.column('title', sa.Text()),
|
# Check if table exists/read data
|
||||||
sa.column('content', sa.Text()),
|
try:
|
||||||
sa.column('timestamp', sa.BigInteger()),
|
existing_prompts = conn.execute(
|
||||||
sa.column('access_control', sa.JSON()),
|
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:
|
).fetchall()
|
||||||
existing_prompts = conn.execute(
|
except Exception:
|
||||||
sa.select(
|
# Fallback if table doesn't exist (new install)
|
||||||
old_prompt_table.c.command,
|
existing_prompts = []
|
||||||
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 = []
|
|
||||||
|
|
||||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY (if not already created)
|
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
|
||||||
if 'prompt_new' not in existing_tables:
|
op.create_table(
|
||||||
op.create_table(
|
'prompt_new',
|
||||||
'prompt_new',
|
sa.Column('id', sa.Text(), primary_key=True),
|
||||||
sa.Column('id', sa.Text(), primary_key=True),
|
sa.Column('command', sa.String(), unique=True, index=True),
|
||||||
sa.Column('command', sa.String(), unique=True, index=True),
|
sa.Column('user_id', sa.String(), nullable=False),
|
||||||
sa.Column('user_id', sa.String(), nullable=False),
|
sa.Column('name', sa.Text(), nullable=False),
|
||||||
sa.Column('name', sa.Text(), nullable=False),
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
sa.Column('content', sa.Text(), nullable=False),
|
sa.Column('data', sa.JSON(), nullable=True),
|
||||||
sa.Column('data', sa.JSON(), nullable=True),
|
sa.Column('meta', sa.JSON(), nullable=True),
|
||||||
sa.Column('meta', sa.JSON(), nullable=True),
|
sa.Column('access_control', 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('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
sa.Column('version_id', sa.Text(), nullable=True),
|
||||||
sa.Column('version_id', sa.Text(), nullable=True),
|
sa.Column('tags', sa.JSON(), nullable=True),
|
||||||
sa.Column('tags', sa.JSON(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Step 3: Create prompt_history table (if not already created)
|
# Step 3: Create prompt_history table
|
||||||
if 'prompt_history' not in existing_tables:
|
op.create_table(
|
||||||
op.create_table(
|
'prompt_history',
|
||||||
'prompt_history',
|
sa.Column('id', sa.Text(), primary_key=True),
|
||||||
sa.Column('id', sa.Text(), primary_key=True),
|
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
|
||||||
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
|
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
sa.Column('snapshot', sa.JSON(), nullable=False),
|
||||||
sa.Column('snapshot', sa.JSON(), nullable=False),
|
sa.Column('user_id', sa.Text(), nullable=False),
|
||||||
sa.Column('user_id', sa.Text(), nullable=False),
|
sa.Column('commit_message', sa.Text(), nullable=True),
|
||||||
sa.Column('commit_message', sa.Text(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Step 4: Migrate data (only if we have old data to migrate)
|
# Step 4: Migrate data
|
||||||
if existing_prompts:
|
prompt_new_table = sa.table(
|
||||||
prompt_new_table = sa.table(
|
'prompt_new',
|
||||||
'prompt_new',
|
sa.column('id', sa.Text()),
|
||||||
sa.column('id', sa.Text()),
|
sa.column('command', sa.String()),
|
||||||
sa.column('command', sa.String()),
|
sa.column('user_id', sa.String()),
|
||||||
sa.column('user_id', sa.String()),
|
sa.column('name', sa.Text()),
|
||||||
sa.column('name', sa.Text()),
|
sa.column('content', sa.Text()),
|
||||||
sa.column('content', sa.Text()),
|
sa.column('data', sa.JSON()),
|
||||||
sa.column('data', sa.JSON()),
|
sa.column('meta', sa.JSON()),
|
||||||
sa.column('meta', sa.JSON()),
|
sa.column('access_control', sa.JSON()),
|
||||||
sa.column('access_control', sa.JSON()),
|
sa.column('is_active', sa.Boolean()),
|
||||||
sa.column('is_active', sa.Boolean()),
|
sa.column('version_id', sa.Text()),
|
||||||
sa.column('version_id', sa.Text()),
|
sa.column('tags', sa.JSON()),
|
||||||
sa.column('tags', sa.JSON()),
|
sa.column('created_at', sa.BigInteger()),
|
||||||
sa.column('created_at', sa.BigInteger()),
|
sa.column('updated_at', sa.BigInteger()),
|
||||||
sa.column('updated_at', sa.BigInteger()),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
prompt_history_table = sa.table(
|
prompt_history_table = sa.table(
|
||||||
'prompt_history',
|
'prompt_history',
|
||||||
sa.column('id', sa.Text()),
|
sa.column('id', sa.Text()),
|
||||||
sa.column('prompt_id', sa.Text()),
|
sa.column('prompt_id', sa.Text()),
|
||||||
sa.column('parent_id', sa.Text()),
|
sa.column('parent_id', sa.Text()),
|
||||||
sa.column('snapshot', sa.JSON()),
|
sa.column('snapshot', sa.JSON()),
|
||||||
sa.column('user_id', sa.Text()),
|
sa.column('user_id', sa.Text()),
|
||||||
sa.column('commit_message', sa.Text()),
|
sa.column('commit_message', sa.Text()),
|
||||||
sa.column('created_at', sa.BigInteger()),
|
sa.column('created_at', sa.BigInteger()),
|
||||||
)
|
)
|
||||||
|
|
||||||
for row in existing_prompts:
|
for row in existing_prompts:
|
||||||
command = row[0]
|
command = row[0]
|
||||||
user_id = row[1]
|
user_id = row[1]
|
||||||
title = row[2]
|
title = row[2]
|
||||||
content = row[3]
|
content = row[3]
|
||||||
timestamp = row[4]
|
timestamp = row[4]
|
||||||
access_control = row[5]
|
access_control = row[5]
|
||||||
|
|
||||||
new_uuid = str(uuid.uuid4())
|
new_uuid = str(uuid.uuid4())
|
||||||
history_uuid = str(uuid.uuid4())
|
history_uuid = str(uuid.uuid4())
|
||||||
clean_command = command[1:] if command and command.startswith('/') else command
|
clean_command = command[1:] if command and command.startswith('/') else command
|
||||||
|
|
||||||
# Insert into prompt_new
|
# Insert into prompt_new
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.insert(prompt_new_table).values(
|
sa.insert(prompt_new_table).values(
|
||||||
id=new_uuid,
|
id=new_uuid,
|
||||||
command=clean_command,
|
command=clean_command,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=title,
|
name=title,
|
||||||
content=content,
|
content=content,
|
||||||
data={},
|
data={},
|
||||||
meta={},
|
meta={},
|
||||||
access_control=access_control,
|
access_control=access_control,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
version_id=history_uuid,
|
version_id=history_uuid,
|
||||||
tags=[],
|
tags=[],
|
||||||
created_at=timestamp,
|
created_at=timestamp,
|
||||||
updated_at=timestamp,
|
updated_at=timestamp,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Create initial history entry
|
# Create initial history entry
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.insert(prompt_history_table).values(
|
sa.insert(prompt_history_table).values(
|
||||||
id=history_uuid,
|
id=history_uuid,
|
||||||
prompt_id=new_uuid,
|
prompt_id=new_uuid,
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
snapshot={
|
snapshot={
|
||||||
'name': title,
|
'name': title,
|
||||||
'content': content,
|
'content': content,
|
||||||
'command': clean_command,
|
'command': clean_command,
|
||||||
'data': {},
|
'data': {},
|
||||||
'meta': {},
|
'meta': {},
|
||||||
'access_control': access_control,
|
'access_control': access_control,
|
||||||
},
|
},
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
commit_message=None,
|
commit_message=None,
|
||||||
created_at=timestamp,
|
created_at=timestamp,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Step 5: Replace old table with new one (only if prompt_new exists)
|
# Step 5: Replace old table with new one
|
||||||
# Re-check tables after potential creation above
|
op.drop_table('prompt')
|
||||||
inspector.clear_cache()
|
op.rename_table('prompt_new', 'prompt')
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-12-30 03:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision = '3781e22d8b01'
|
revision = '3781e22d8b01'
|
||||||
down_revision = '7826ab40b532'
|
down_revision = '7826ab40b532'
|
||||||
|
|
@ -16,50 +16,38 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
existing_tables = set(inspector.get_table_names())
|
|
||||||
|
|
||||||
# Add 'type' column to the 'channel' table
|
# Add 'type' column to the 'channel' table
|
||||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
op.add_column(
|
||||||
if 'type' not in channel_cols:
|
'channel',
|
||||||
op.add_column(
|
sa.Column(
|
||||||
'channel',
|
'type',
|
||||||
sa.Column(
|
sa.Text(),
|
||||||
'type',
|
nullable=True,
|
||||||
sa.Text(),
|
),
|
||||||
nullable=True,
|
)
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add 'parent_id' column to the 'message' table for threads
|
# Add 'parent_id' column to the 'message' table for threads
|
||||||
message_cols = {c['name'] for c in inspector.get_columns('message')}
|
op.add_column(
|
||||||
if 'parent_id' not in message_cols:
|
'message',
|
||||||
op.add_column(
|
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||||
'message',
|
)
|
||||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'message_reaction' not in existing_tables:
|
op.create_table(
|
||||||
op.create_table(
|
'message_reaction',
|
||||||
'message_reaction',
|
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
|
||||||
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('user_id', sa.Text(), nullable=False), # User who reacted
|
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
|
||||||
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('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
|
||||||
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(
|
||||||
op.create_table(
|
'channel_member',
|
||||||
'channel_member',
|
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
|
||||||
sa.Column(
|
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
|
||||||
'id', sa.Text(), nullable=False, primary_key=True, unique=True
|
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
|
||||||
), # Record ID for the membership row
|
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
|
||||||
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():
|
def downgrade():
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ Create Date: 2025-11-17 03:45:25.123939
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import time
|
||||||
|
import json
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '37f288994c47'
|
revision: str = '37f288994c47'
|
||||||
|
|
@ -22,13 +22,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> 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
|
# 1. Create new table
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'group_member',
|
'group_member',
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ Create Date: 2025-09-08 14:19:59.583921
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '38d63c18f30f'
|
revision: str = '38d63c18f30f'
|
||||||
|
|
@ -19,39 +19,50 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
|
||||||
inspector = sa.inspect(op.get_bind())
|
inspector = sa.inspect(op.get_bind())
|
||||||
existing_tables = set(inspector.get_table_names())
|
columns = inspector.get_columns('user')
|
||||||
|
|
||||||
# ── Create oauth_session table (idempotent) ───────────────────────
|
pk_columns = inspector.get_pk_constraint('user')['constrained_columns']
|
||||||
if 'oauth_session' not in existing_tables:
|
id_column = next((col for col in columns if col['name'] == 'id'), None)
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes (idempotent — no-ops when table was just created
|
if id_column and not id_column.get('unique', False):
|
||||||
# with the columns above, and safe to call if indexes already exist).
|
unique_constraints = inspector.get_unique_constraints('user')
|
||||||
existing_indexes = (
|
unique_columns = {tuple(u['column_names']) for u in unique_constraints}
|
||||||
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
|
|
||||||
|
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:
|
# Create indexes for better performance
|
||||||
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
|
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'])
|
||||||
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'])
|
||||||
if 'idx_oauth_session_user_provider' not in existing_indexes:
|
|
||||||
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.sql import table, select, update, column
|
||||||
from sqlalchemy.engine.reflection import Inspector
|
from sqlalchemy.engine.reflection import Inspector
|
||||||
from sqlalchemy.sql import column, select, table, update
|
|
||||||
|
import json
|
||||||
|
|
||||||
revision = '3ab32c4b8f59'
|
revision = '3ab32c4b8f59'
|
||||||
down_revision = '1af9b942657b'
|
down_revision = '1af9b942657b'
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ Create Date: 2025-08-21 02:07:18.078283
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '3af16a1c9fb6'
|
revision: str = '3af16a1c9fb6'
|
||||||
|
|
@ -19,18 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
|
||||||
inspector = sa.inspect(conn)
|
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
|
||||||
user_cols = {c['name'] for c in inspector.get_columns('user')}
|
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
|
||||||
|
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
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
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import open_webui.internal.db
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '3e0e00844bb0'
|
revision: str = '3e0e00844bb0'
|
||||||
|
|
@ -24,13 +25,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> 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(
|
op.create_table(
|
||||||
'knowledge_file',
|
'knowledge_file',
|
||||||
sa.Column('id', sa.Text(), primary_key=True),
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision = '4ace53fd72c8'
|
revision = '4ace53fd72c8'
|
||||||
down_revision = 'af906e964978'
|
down_revision = 'af906e964978'
|
||||||
|
|
@ -16,18 +16,6 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
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
|
# Perform safe alterations using batch operation
|
||||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||||
# Step 1: Remove server defaults for created_at and updated_at
|
# Step 1: Remove server defaults for created_at and updated_at
|
||||||
|
|
@ -60,24 +48,20 @@ def upgrade():
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# Convert columns back to DateTime and restore defaults. Mirrors the
|
# Downgrade: Convert columns back to DateTime and restore defaults
|
||||||
# upgrade's postgresql_using cast — without it, Postgres can't
|
|
||||||
# auto-cast BigInteger → timestamp and aborts with DatatypeMismatch.
|
|
||||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||||
batch_op.alter_column(
|
batch_op.alter_column(
|
||||||
'created_at',
|
'created_at',
|
||||||
type_=sa.DateTime(),
|
type_=sa.DateTime(),
|
||||||
existing_type=sa.BigInteger(),
|
existing_type=sa.BigInteger(),
|
||||||
existing_nullable=False,
|
existing_nullable=False,
|
||||||
server_default=sa.func.now(),
|
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||||
postgresql_using='to_timestamp(created_at)::timestamp without time zone',
|
|
||||||
)
|
)
|
||||||
batch_op.alter_column(
|
batch_op.alter_column(
|
||||||
'updated_at',
|
'updated_at',
|
||||||
type_=sa.DateTime(),
|
type_=sa.DateTime(),
|
||||||
existing_type=sa.BigInteger(),
|
existing_type=sa.BigInteger(),
|
||||||
existing_nullable=False,
|
existing_nullable=False,
|
||||||
server_default=sa.func.now(),
|
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||||
onupdate=sa.func.now(),
|
onupdate=sa.func.now(), # Restoring onupdate behavior if it was there
|
||||||
postgresql_using='to_timestamp(updated_at)::timestamp without time zone',
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
|
||||||
|
|
@ -8,9 +8,10 @@ Create Date: 2026-05-09 04:29:27.651341
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import open_webui.internal.db
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '4de81c2a3af1'
|
revision: str = '4de81c2a3af1'
|
||||||
|
|
@ -19,55 +20,46 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import time
|
||||||
from sqlalchemy import insert, select, update
|
from sqlalchemy import select, update, insert
|
||||||
from sqlalchemy.sql import column, table
|
from sqlalchemy.sql import table, column
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
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'),
|
||||||
|
)
|
||||||
|
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
existing_tables = set(inspector.get_table_names())
|
|
||||||
|
|
||||||
if 'pinned_note' not in existing_tables:
|
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
|
||||||
op.create_table(
|
|
||||||
'pinned_note',
|
pinned_note_table = table(
|
||||||
sa.Column('id', sa.Text(), nullable=False),
|
'pinned_note',
|
||||||
sa.Column('user_id', sa.Text(), nullable=False),
|
column('id', sa.Text),
|
||||||
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
|
column('user_id', sa.Text),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
column('note_id', sa.Text),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
column('created_at', sa.BigInteger),
|
||||||
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
|
)
|
||||||
|
|
||||||
|
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],
|
||||||
)
|
)
|
||||||
|
|
||||||
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
|
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_pinned')
|
||||||
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:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -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
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '56359461a091'
|
revision: str = '56359461a091'
|
||||||
|
|
@ -18,86 +19,58 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: 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:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
op.create_table(
|
||||||
inspector = sa.inspect(conn)
|
'calendar',
|
||||||
tables = inspector.get_table_names()
|
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(
|
||||||
op.create_table(
|
'calendar_event',
|
||||||
'calendar',
|
sa.Column('id', sa.Text(), nullable=False),
|
||||||
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('user_id', sa.Text(), nullable=False),
|
||||||
sa.Column('name', sa.Text(), nullable=False),
|
sa.Column('title', sa.Text(), nullable=False),
|
||||||
sa.Column('color', sa.Text(), nullable=True),
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
sa.Column('start_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('data', sa.JSON(), nullable=True),
|
sa.Column('end_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('meta', sa.JSON(), nullable=True),
|
sa.Column('all_day', sa.Boolean(), nullable=False),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
sa.Column('rrule', sa.Text(), nullable=True),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
sa.Column('color', sa.Text(), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
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()
|
op.create_table(
|
||||||
if 'calendar' in inspector.get_table_names():
|
'calendar_event_attendee',
|
||||||
if not _index_exists(inspector, 'ix_calendar_user', 'calendar'):
|
sa.Column('id', sa.Text(), nullable=False),
|
||||||
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
|
sa.Column('event_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Text(), nullable=False),
|
||||||
if 'calendar_event' not in tables:
|
sa.Column('status', sa.Text(), nullable=False),
|
||||||
op.create_table(
|
sa.Column('meta', sa.JSON(), nullable=True),
|
||||||
'calendar_event',
|
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('id', sa.Text(), nullable=False),
|
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('calendar_id', sa.Text(), nullable=False),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.Column('user_id', sa.Text(), nullable=False),
|
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
|
||||||
sa.Column('title', sa.Text(), nullable=False),
|
)
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ Create Date: 2024-12-22 03:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision = '57c599a3cb57'
|
revision = '57c599a3cb57'
|
||||||
down_revision = '922e7a387820'
|
down_revision = '922e7a387820'
|
||||||
|
|
@ -16,36 +16,30 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
op.create_table(
|
||||||
inspector = sa.inspect(conn)
|
'channel',
|
||||||
existing_tables = set(inspector.get_table_names())
|
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(
|
||||||
op.create_table(
|
'message',
|
||||||
'channel',
|
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
sa.Column('user_id', sa.Text()),
|
||||||
sa.Column('user_id', sa.Text()),
|
sa.Column('channel_id', sa.Text(), nullable=True),
|
||||||
sa.Column('name', sa.Text()),
|
sa.Column('content', sa.Text()),
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
sa.Column('data', sa.JSON(), nullable=True),
|
||||||
sa.Column('data', sa.JSON(), nullable=True),
|
sa.Column('meta', sa.JSON(), nullable=True),
|
||||||
sa.Column('meta', sa.JSON(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
sa.Column('updated_at', sa.BigInteger(), 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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ Create Date: 2025-12-10 15:11:39.424601
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import open_webui.internal.db
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '6283dc0e4d8d'
|
revision: str = '6283dc0e4d8d'
|
||||||
|
|
@ -20,38 +20,31 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
op.create_table(
|
||||||
inspector = sa.inspect(conn)
|
'channel_file',
|
||||||
existing_tables = set(inspector.get_table_names())
|
sa.Column('id', sa.Text(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Text(), nullable=False),
|
||||||
if 'channel_file' not in existing_tables:
|
sa.Column(
|
||||||
op.create_table(
|
'channel_id',
|
||||||
'channel_file',
|
sa.Text(),
|
||||||
sa.Column('id', sa.Text(), primary_key=True),
|
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||||
sa.Column('user_id', sa.Text(), nullable=False),
|
nullable=False,
|
||||||
sa.Column(
|
),
|
||||||
'channel_id',
|
sa.Column(
|
||||||
sa.Text(),
|
'file_id',
|
||||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
sa.Text(),
|
||||||
nullable=False,
|
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||||
),
|
nullable=False,
|
||||||
sa.Column(
|
),
|
||||||
'file_id',
|
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Text(),
|
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
# indexes
|
||||||
nullable=False,
|
sa.Index('ix_channel_file_channel_id', 'channel_id'),
|
||||||
),
|
sa.Index('ix_channel_file_file_id', 'file_id'),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
sa.Index('ix_channel_file_user_id', 'user_id'),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
# unique constraints
|
||||||
# indexes
|
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
|
||||||
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:
|
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 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'
|
revision = '6a39f3d8e55c'
|
||||||
down_revision = 'c0fbf31ca0db'
|
down_revision = 'c0fbf31ca0db'
|
||||||
|
|
@ -19,67 +18,62 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
# Creating the 'knowledge' table
|
||||||
inspector = sa.inspect(conn)
|
print('Creating knowledge table')
|
||||||
existing_tables = set(inspector.get_table_names())
|
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:
|
print('Migrating data from document table to knowledge table')
|
||||||
# Creating the 'knowledge' table
|
# Representation of the existing 'document' table
|
||||||
print('Creating knowledge table')
|
document_table = table(
|
||||||
knowledge_table = op.create_table(
|
'document',
|
||||||
'knowledge',
|
column('collection_name', sa.String()),
|
||||||
sa.Column('id', sa.Text(), primary_key=True),
|
column('user_id', sa.String()),
|
||||||
sa.Column('user_id', sa.Text(), nullable=False),
|
column('name', sa.String()),
|
||||||
sa.Column('name', sa.Text(), nullable=False),
|
column('title', sa.Text()),
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
column('content', sa.Text()),
|
||||||
sa.Column('data', sa.JSON(), nullable=True),
|
column('timestamp', sa.BigInteger()),
|
||||||
sa.Column('meta', sa.JSON(), nullable=True),
|
)
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
# 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')
|
# Insert data into knowledge table from document table
|
||||||
# Representation of the existing 'document' table
|
for doc in documents:
|
||||||
document_table = table(
|
op.get_bind().execute(
|
||||||
'document',
|
knowledge_table.insert().values(
|
||||||
column('collection_name', sa.String()),
|
id=doc.collection_name,
|
||||||
column('user_id', sa.String()),
|
user_id=doc.user_id,
|
||||||
column('name', sa.String()),
|
description=doc.name,
|
||||||
column('title', sa.Text()),
|
meta={
|
||||||
column('content', sa.Text()),
|
'legacy': True,
|
||||||
column('timestamp', sa.BigInteger()),
|
'document': True,
|
||||||
)
|
'tags': json.loads(doc.content or '{}').get('tags', []),
|
||||||
|
},
|
||||||
# Select all from existing document table
|
name=doc.title,
|
||||||
documents = conn.execute(
|
created_at=doc.timestamp,
|
||||||
select(
|
updated_at=doc.timestamp, # using created_at for both created_at and updated_at in project
|
||||||
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:
|
|
||||||
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():
|
def downgrade():
|
||||||
op.drop_table('knowledge')
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision = '7826ab40b532'
|
revision = '7826ab40b532'
|
||||||
down_revision = '57c599a3cb57'
|
down_revision = '57c599a3cb57'
|
||||||
|
|
@ -16,15 +16,10 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
op.add_column(
|
||||||
inspector = sa.inspect(conn)
|
'file',
|
||||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
if 'access_control' not in file_cols:
|
|
||||||
op.add_column(
|
|
||||||
'file',
|
|
||||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
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.
|
"""init
|
||||||
# Revision ID: 7e5b5dc7342b
|
|
||||||
# Revises: (none)
|
Revision ID: 7e5b5dc7342b
|
||||||
# Created on: 2024-06-24 13:15:33.808998
|
Revises:
|
||||||
from __future__ import annotations
|
Create Date: 2024-06-24 13:15:33.808998
|
||||||
from typing import Sequence
|
|
||||||
import open_webui.internal.db # noqa: F401
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
import open_webui.internal.db
|
||||||
from open_webui.internal.db import JSONField
|
from open_webui.internal.db import JSONField
|
||||||
from open_webui.migrations.util import get_existing_tables
|
from open_webui.migrations.util import get_existing_tables
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '7e5b5dc7342b'
|
revision: str = '7e5b5dc7342b'
|
||||||
down_revision: str | None = None
|
down_revision: Union[str, None] = None
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
# Initial schema table declarations
|
|
||||||
_INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
|
||||||
(
|
def upgrade() -> None:
|
||||||
'auth',
|
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('id', sa.String(), nullable=False),
|
||||||
sa.Column('email', sa.String(), nullable=True),
|
sa.Column('email', sa.String(), nullable=True),
|
||||||
sa.Column('password', sa.Text(), nullable=True),
|
sa.Column('password', sa.Text(), nullable=True),
|
||||||
sa.Column('active', sa.Boolean(), nullable=True),
|
sa.Column('active', sa.Boolean(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'chat' not in existing_tables:
|
||||||
'chat',
|
op.create_table(
|
||||||
[
|
'chat',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('title', sa.Text(), 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('updated_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('share_id', sa.Text(), nullable=True),
|
sa.Column('share_id', sa.Text(), nullable=True),
|
||||||
sa.Column('archived', sa.Boolean(), nullable=True),
|
sa.Column('archived', sa.Boolean(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('share_id')],
|
sa.UniqueConstraint('share_id'),
|
||||||
),
|
)
|
||||||
(
|
|
||||||
'chatidtag',
|
if 'chatidtag' not in existing_tables:
|
||||||
[
|
op.create_table(
|
||||||
|
'chatidtag',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('tag_name', sa.String(), nullable=True),
|
sa.Column('tag_name', sa.String(), nullable=True),
|
||||||
sa.Column('chat_id', sa.String(), nullable=True),
|
sa.Column('chat_id', sa.String(), nullable=True),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'document' not in existing_tables:
|
||||||
'document',
|
op.create_table(
|
||||||
[
|
'document',
|
||||||
sa.Column('collection_name', sa.String(), nullable=False),
|
sa.Column('collection_name', sa.String(), nullable=False),
|
||||||
sa.Column('name', sa.String(), nullable=True),
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
sa.Column('title', sa.Text(), 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('content', sa.Text(), nullable=True),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('collection_name'),
|
||||||
[sa.PrimaryKeyConstraint('collection_name'), sa.UniqueConstraint('name')],
|
sa.UniqueConstraint('name'),
|
||||||
),
|
)
|
||||||
(
|
|
||||||
'file',
|
if 'file' not in existing_tables:
|
||||||
[
|
op.create_table(
|
||||||
|
'file',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('filename', sa.Text(), nullable=True),
|
sa.Column('filename', sa.Text(), nullable=True),
|
||||||
sa.Column('meta', JSONField(), nullable=True),
|
sa.Column('meta', JSONField(), nullable=True),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'function' not in existing_tables:
|
||||||
'function',
|
op.create_table(
|
||||||
[
|
'function',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('name', sa.Text(), 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('is_global', sa.Boolean(), nullable=True),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'memory' not in existing_tables:
|
||||||
'memory',
|
op.create_table(
|
||||||
[
|
'memory',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('content', sa.Text(), nullable=True),
|
sa.Column('content', sa.Text(), nullable=True),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'model' not in existing_tables:
|
||||||
'model',
|
op.create_table(
|
||||||
[
|
'model',
|
||||||
sa.Column('id', sa.Text(), nullable=False),
|
sa.Column('id', sa.Text(), nullable=False),
|
||||||
sa.Column('user_id', sa.Text(), nullable=True),
|
sa.Column('user_id', sa.Text(), nullable=True),
|
||||||
sa.Column('base_model_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('meta', JSONField(), nullable=True),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'prompt' not in existing_tables:
|
||||||
'prompt',
|
op.create_table(
|
||||||
[
|
'prompt',
|
||||||
sa.Column('command', sa.String(), nullable=False),
|
sa.Column('command', sa.String(), nullable=False),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('title', sa.Text(), nullable=True),
|
sa.Column('title', sa.Text(), nullable=True),
|
||||||
sa.Column('content', sa.Text(), nullable=True),
|
sa.Column('content', sa.Text(), nullable=True),
|
||||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('command'),
|
||||||
[sa.PrimaryKeyConstraint('command')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'tag' not in existing_tables:
|
||||||
'tag',
|
op.create_table(
|
||||||
[
|
'tag',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('name', sa.String(), nullable=True),
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('data', sa.Text(), nullable=True),
|
sa.Column('data', sa.Text(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'tool' not in existing_tables:
|
||||||
'tool',
|
op.create_table(
|
||||||
[
|
'tool',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('user_id', sa.String(), nullable=True),
|
sa.Column('user_id', sa.String(), nullable=True),
|
||||||
sa.Column('name', sa.Text(), 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('valves', JSONField(), nullable=True),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
],
|
sa.PrimaryKeyConstraint('id'),
|
||||||
[sa.PrimaryKeyConstraint('id')],
|
)
|
||||||
),
|
|
||||||
(
|
if 'user' not in existing_tables:
|
||||||
'user',
|
op.create_table(
|
||||||
[
|
'user',
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
sa.Column('name', sa.String(), nullable=True),
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
sa.Column('email', 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('settings', JSONField(), nullable=True),
|
||||||
sa.Column('info', JSONField(), nullable=True),
|
sa.Column('info', JSONField(), nullable=True),
|
||||||
sa.Column('oauth_sub', sa.Text(), nullable=True),
|
sa.Column('oauth_sub', sa.Text(), nullable=True),
|
||||||
],
|
|
||||||
[
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.UniqueConstraint('api_key'),
|
sa.UniqueConstraint('api_key'),
|
||||||
sa.UniqueConstraint('oauth_sub'),
|
sa.UniqueConstraint('oauth_sub'),
|
||||||
],
|
)
|
||||||
),
|
# ### end Alembic commands ###
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --- migration execution ---
|
def downgrade() -> None:
|
||||||
def upgrade() -> None: # deploy initial schema tables
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
existing_tables = set(get_existing_tables())
|
op.drop_table('user')
|
||||||
for name, columns, constraints in _INITIAL_TABLES:
|
op.drop_table('tool')
|
||||||
if name not in existing_tables:
|
op.drop_table('tag')
|
||||||
op.create_table(name, *columns, *constraints)
|
op.drop_table('prompt')
|
||||||
|
op.drop_table('model')
|
||||||
|
op.drop_table('memory')
|
||||||
# --- rollback function ---
|
op.drop_table('function')
|
||||||
def downgrade() -> None: # rollback initial schema tables
|
op.drop_table('file')
|
||||||
for table_name, _, _ in reversed(_INITIAL_TABLES):
|
op.drop_table('document')
|
||||||
op.drop_table(table_name)
|
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
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import open_webui.internal.db
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '81cc2ce44d79'
|
revision: str = '81cc2ce44d79'
|
||||||
|
|
@ -20,27 +20,20 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
|
|
||||||
# Add message_id column to channel_file table
|
# Add message_id column to channel_file table
|
||||||
cf_cols = {c['name'] for c in inspector.get_columns('channel_file')}
|
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
||||||
if 'message_id' not in cf_cols:
|
batch_op.add_column(
|
||||||
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
sa.Column(
|
||||||
batch_op.add_column(
|
'message_id',
|
||||||
sa.Column(
|
sa.Text(),
|
||||||
'message_id',
|
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
|
||||||
sa.Text(),
|
nullable=True,
|
||||||
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Add data column to knowledge table
|
# Add data column to knowledge table
|
||||||
k_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
with op.batch_alter_table('knowledge', schema=None) as batch_op:
|
||||||
if 'data' not in k_cols:
|
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:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ Create Date: 2026-02-01 04:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -56,13 +56,6 @@ def _flush_batch(conn, table, batch):
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
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
|
# Step 1: Create table
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'chat_message',
|
'chat_message',
|
||||||
|
|
@ -92,6 +85,8 @@ def upgrade() -> None:
|
||||||
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
|
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
|
||||||
|
|
||||||
# Step 2: Backfill from existing chats
|
# Step 2: Backfill from existing chats
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
chat_table = sa.table(
|
chat_table = sa.table(
|
||||||
'chat',
|
'chat',
|
||||||
sa.column('id', sa.Text()),
|
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
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import open_webui.internal.db
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '90ef40d4714e'
|
revision: str = '90ef40d4714e'
|
||||||
|
|
@ -20,53 +20,42 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
existing_tables = set(inspector.get_table_names())
|
|
||||||
|
|
||||||
# Update 'channel' table
|
# Update 'channel' table
|
||||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
|
||||||
if 'is_private' not in channel_cols:
|
|
||||||
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
|
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
|
||||||
if 'archived_at' not in channel_cols:
|
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
|
||||||
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('deleted_at', sa.BigInteger(), nullable=True))
|
||||||
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
|
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
|
||||||
if 'deleted_at' not in channel_cols:
|
|
||||||
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
|
op.add_column('channel', sa.Column('updated_by', sa.Text(), 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))
|
|
||||||
|
|
||||||
# Update 'channel_member' table
|
# Update 'channel_member' table
|
||||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
|
||||||
if 'role' not in cm_cols:
|
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
|
||||||
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
|
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), 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))
|
|
||||||
|
|
||||||
# Create 'channel_webhook' table
|
# Create 'channel_webhook' table
|
||||||
if 'channel_webhook' not in existing_tables:
|
op.create_table(
|
||||||
op.create_table(
|
'channel_webhook',
|
||||||
'channel_webhook',
|
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||||
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
sa.Column('user_id', sa.Text(), nullable=False),
|
||||||
sa.Column('user_id', sa.Text(), nullable=False),
|
sa.Column(
|
||||||
sa.Column(
|
'channel_id',
|
||||||
'channel_id',
|
sa.Text(),
|
||||||
sa.Text(),
|
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
nullable=False,
|
||||||
nullable=False,
|
),
|
||||||
),
|
sa.Column('name', sa.Text(), nullable=False),
|
||||||
sa.Column('name', sa.Text(), nullable=False),
|
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
||||||
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
sa.Column('token', sa.Text(), nullable=False),
|
||||||
sa.Column('token', sa.Text(), nullable=False),
|
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
)
|
||||||
)
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
@ -85,3 +74,5 @@ def downgrade() -> None:
|
||||||
|
|
||||||
# Drop 'channel_webhook' table
|
# Drop 'channel_webhook' table
|
||||||
op.drop_table('channel_webhook')
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision = '922e7a387820'
|
revision = '922e7a387820'
|
||||||
down_revision = '4ace53fd72c8'
|
down_revision = '4ace53fd72c8'
|
||||||
|
|
@ -16,55 +16,70 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
op.create_table(
|
||||||
inspector = sa.inspect(conn)
|
'group',
|
||||||
existing_tables = set(inspector.get_table_names())
|
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||||
|
sa.Column('user_id', sa.Text(), nullable=True),
|
||||||
if 'group' not in existing_tables:
|
sa.Column('name', sa.Text(), nullable=True),
|
||||||
op.create_table(
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
'group',
|
sa.Column('data', sa.JSON(), nullable=True),
|
||||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
sa.Column('meta', sa.JSON(), nullable=True),
|
||||||
sa.Column('user_id', sa.Text(), nullable=True),
|
sa.Column('permissions', sa.JSON(), nullable=True),
|
||||||
sa.Column('name', sa.Text(), nullable=True),
|
sa.Column('user_ids', sa.JSON(), nullable=True),
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('data', sa.JSON(), nullable=True),
|
sa.Column('updated_at', sa.BigInteger(), 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
|
# Add 'access_control' column to 'model' table
|
||||||
model_cols = {c['name'] for c in inspector.get_columns('model')}
|
op.add_column(
|
||||||
if 'access_control' not in model_cols:
|
'model',
|
||||||
op.add_column('model', sa.Column('access_control', sa.JSON(), nullable=True))
|
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||||
if 'is_active' not in model_cols:
|
)
|
||||||
op.add_column(
|
|
||||||
'model',
|
# Add 'is_active' column to 'model' table
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
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
|
# Add 'access_control' column to 'knowledge' table
|
||||||
knowledge_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
op.add_column(
|
||||||
if 'access_control' not in knowledge_cols:
|
'knowledge',
|
||||||
op.add_column('knowledge', sa.Column('access_control', sa.JSON(), nullable=True))
|
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
# Add 'access_control' column to 'prompt' table
|
# Add 'access_control' column to 'prompt' table
|
||||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
op.add_column(
|
||||||
if 'access_control' not in prompt_cols:
|
'prompt',
|
||||||
op.add_column('prompt', sa.Column('access_control', sa.JSON(), nullable=True))
|
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
# Add 'access_control' column to 'tools' table
|
# Add 'access_control' column to 'tools' table
|
||||||
tool_cols = {c['name'] for c in inspector.get_columns('tool')}
|
op.add_column(
|
||||||
if 'access_control' not in tool_cols:
|
'tool',
|
||||||
op.add_column('tool', sa.Column('access_control', sa.JSON(), nullable=True))
|
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
op.drop_table('group')
|
op.drop_table('group')
|
||||||
|
|
||||||
|
# Drop 'access_control' column from 'model' table
|
||||||
op.drop_column('model', 'access_control')
|
op.drop_column('model', 'access_control')
|
||||||
|
|
||||||
|
# Drop 'is_active' column from 'model' table
|
||||||
op.drop_column('model', 'is_active')
|
op.drop_column('model', 'is_active')
|
||||||
|
|
||||||
|
# Drop 'access_control' column from 'knowledge' table
|
||||||
op.drop_column('knowledge', 'access_control')
|
op.drop_column('knowledge', 'access_control')
|
||||||
|
|
||||||
|
# Drop 'access_control' column from 'prompt' table
|
||||||
op.drop_column('prompt', 'access_control')
|
op.drop_column('prompt', 'access_control')
|
||||||
|
|
||||||
|
# Drop 'access_control' column from 'tools' table
|
||||||
op.drop_column('tool', 'access_control')
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
revision = '9f0c9cd09105'
|
revision = '9f0c9cd09105'
|
||||||
down_revision = '3781e22d8b01'
|
down_revision = '3781e22d8b01'
|
||||||
|
|
@ -16,22 +16,17 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
op.create_table(
|
||||||
inspector = sa.inspect(conn)
|
'note',
|
||||||
existing_tables = set(inspector.get_table_names())
|
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||||
|
sa.Column('user_id', sa.Text(), nullable=True),
|
||||||
if 'note' not in existing_tables:
|
sa.Column('title', sa.Text(), nullable=True),
|
||||||
op.create_table(
|
sa.Column('data', sa.JSON(), nullable=True),
|
||||||
'note',
|
sa.Column('meta', sa.JSON(), nullable=True),
|
||||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||||
sa.Column('user_id', sa.Text(), nullable=True),
|
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||||
sa.Column('title', sa.Text(), nullable=True),
|
sa.Column('updated_at', sa.BigInteger(), 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():
|
def downgrade():
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ Create Date: 2025-09-15 03:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision = 'a0b1c2d3e4f5'
|
revision = 'a0b1c2d3e4f5'
|
||||||
|
|
@ -16,12 +15,7 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
|
||||||
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():
|
def downgrade():
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ Create Date: 2026-02-11 09:30:00.000000
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from open_webui.migrations.util import get_existing_tables
|
from open_webui.migrations.util import get_existing_tables
|
||||||
|
|
||||||
revision: str = 'a1b2c3d4e5f6'
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ Create Date: 2026-03-29 22:15:00.000000
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'a3dd5bedd151'
|
revision: str = 'a3dd5bedd151'
|
||||||
|
|
@ -19,14 +19,8 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
|
||||||
inspector = sa.inspect(conn)
|
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ Create Date: 2025-09-27 02:24:18.058455
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'a5c220713937'
|
revision: str = 'a5c220713937'
|
||||||
|
|
@ -19,18 +19,16 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> 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
|
# Add 'reply_to_id' column to the 'message' table for replying to messages
|
||||||
if 'reply_to_id' not in msg_cols:
|
op.add_column(
|
||||||
op.add_column(
|
'message',
|
||||||
'message',
|
sa.Column('reply_to_id', sa.Text(), nullable=True),
|
||||||
sa.Column('reply_to_id', sa.Text(), nullable=True),
|
)
|
||||||
)
|
pass
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# Remove 'reply_to_id' column from the 'message' table
|
# Remove 'reply_to_id' column from the 'message' table
|
||||||
op.drop_column('message', 'reply_to_id')
|
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
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# Revision identifiers, used by Alembic.
|
# Revision identifiers, used by Alembic.
|
||||||
revision = 'af906e964978'
|
revision = 'af906e964978'
|
||||||
|
|
@ -17,28 +17,23 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
# ### Create feedback table ###
|
||||||
inspector = sa.inspect(conn)
|
op.create_table(
|
||||||
existing_tables = set(inspector.get_table_names())
|
'feedback',
|
||||||
|
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
|
||||||
if 'feedback' not in existing_tables:
|
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
|
||||||
# ### Create feedback table ###
|
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
|
||||||
op.create_table(
|
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
||||||
'feedback',
|
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
|
||||||
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
|
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
|
||||||
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
|
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
|
||||||
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
|
sa.Column(
|
||||||
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
'created_at', sa.BigInteger(), nullable=False
|
||||||
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
|
), # Feedback creation timestamp (BIGINT representing epoch)
|
||||||
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
|
sa.Column(
|
||||||
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
|
'updated_at', sa.BigInteger(), nullable=False
|
||||||
sa.Column(
|
), # Feedback update timestamp (BIGINT representing epoch)
|
||||||
'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():
|
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
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import open_webui.internal.db
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
import open_webui.internal.db
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'b10670c03dd5'
|
revision: str = 'b10670c03dd5'
|
||||||
|
|
@ -20,49 +22,20 @@ down_revision: Union[str, None] = '2f1211949ecc'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: 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):
|
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
|
||||||
"""
|
"""
|
||||||
SQLite requires manual removal of any user-created indexes referencing
|
SQLite requires manual removal of any indexes referencing a column
|
||||||
a column before ALTER TABLE ... DROP COLUMN can succeed.
|
before ALTER TABLE ... DROP COLUMN can succeed.
|
||||||
|
|
||||||
NOTE: PRAGMAs have no Core equivalent — raw text is unavoidable here.
|
|
||||||
"""
|
"""
|
||||||
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
|
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
|
||||||
|
|
||||||
for idx in indexes:
|
for idx in indexes:
|
||||||
index_name = idx[1] # index name
|
index_name = idx[1] # index name
|
||||||
# Skip system-managed autoindexes (PK / UNIQUE constraints) — they
|
# Get indexed columns
|
||||||
# cannot be dropped directly and will disappear when the column is
|
|
||||||
# removed via batch_alter_table.
|
|
||||||
if index_name.startswith('sqlite_autoindex_'):
|
|
||||||
continue
|
|
||||||
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
|
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:
|
if column_name in indexed_cols:
|
||||||
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
|
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()
|
conn = op.get_bind()
|
||||||
dialect = conn.dialect.name
|
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
|
# SQLite cannot ALTER COLUMN → must recreate column
|
||||||
if dialect == 'sqlite':
|
if dialect == 'sqlite':
|
||||||
|
# 1. Add temporary column
|
||||||
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
|
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:
|
if raw is None:
|
||||||
parsed = None
|
parsed = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
parsed = None
|
parsed = None # fallback safe behavior
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_json', sa.JSON)))
|
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
|
||||||
.where(sa.column('id') == uid)
|
{'val': json.dumps(parsed) if parsed else None, 'id': uid},
|
||||||
.values({f'{column}_json': parsed})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 3. Drop old TEXT column
|
||||||
op.drop_column(table, column)
|
op.drop_column(table, column)
|
||||||
|
|
||||||
|
# 4. Rename new JSON column → original name
|
||||||
op.alter_column(table, f'{column}_json', new_column_name=column)
|
op.alter_column(table, f'{column}_json', new_column_name=column)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
@ -111,18 +87,15 @@ def _convert_column_to_text(table: str, column: str):
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
dialect = conn.dialect.name
|
dialect = conn.dialect.name
|
||||||
|
|
||||||
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.JSON))
|
|
||||||
|
|
||||||
if dialect == 'sqlite':
|
if dialect == 'sqlite':
|
||||||
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
|
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:
|
for uid, raw in rows:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_text', sa.Text)))
|
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
|
||||||
.where(sa.column('id') == uid)
|
{'val': json.dumps(raw) if raw else None, 'id': uid},
|
||||||
.values({f'{column}_text': json.dumps(raw) if raw is not None else None})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
op.drop_column(table, column)
|
op.drop_column(table, column)
|
||||||
|
|
@ -138,110 +111,115 @@ def _convert_column_to_text(table: str, column: str):
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
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()
|
conn = op.get_bind()
|
||||||
inspector = sa.inspect(conn)
|
users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall()
|
||||||
existing_tables = set(inspector.get_table_names())
|
|
||||||
user_columns = {c['name'] for c in inspector.get_columns('user')}
|
|
||||||
|
|
||||||
# ── Add new columns (idempotent) ──────────────────────────────────
|
for uid, oauth_sub in users:
|
||||||
for col_name, col_type in [
|
if oauth_sub:
|
||||||
('profile_banner_image_url', sa.Text()),
|
# Example formats supported:
|
||||||
('timezone', sa.String()),
|
# provider@sub
|
||||||
('presence_state', sa.String()),
|
# plain sub (stored as {"oidc": {"sub": sub}})
|
||||||
('status_emoji', sa.String()),
|
if '@' in oauth_sub:
|
||||||
('status_message', sa.Text()),
|
provider, sub = oauth_sub.split('@', 1)
|
||||||
('status_expires_at', sa.BigInteger()),
|
else:
|
||||||
('oauth', sa.JSON()),
|
provider, sub = 'oidc', oauth_sub
|
||||||
]:
|
|
||||||
if col_name not in user_columns:
|
|
||||||
op.add_column('user', sa.Column(col_name, col_type, nullable=True))
|
|
||||||
|
|
||||||
# Convert info (TEXT/JSONField) → JSON (skip if already JSON)
|
oauth_json = json.dumps({provider: {'sub': sub}})
|
||||||
user_col_types = {c['name']: c['type'] for c in inspector.get_columns('user')}
|
conn.execute(
|
||||||
if isinstance(user_col_types.get('info'), sa.Text):
|
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
|
||||||
_convert_column_to_json('user', 'info')
|
{'oauth': oauth_json, 'id': uid},
|
||||||
# Convert settings (TEXT/JSONField) → JSON (skip if already JSON)
|
)
|
||||||
if isinstance(user_col_types.get('settings'), sa.Text):
|
|
||||||
_convert_column_to_json('user', 'settings')
|
|
||||||
|
|
||||||
# ── Create api_key table (idempotent) ─────────────────────────────
|
users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall()
|
||||||
if 'api_key' not in existing_tables:
|
now = int(time.time())
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
|
for uid, api_key in users_with_keys:
|
||||||
if 'oauth_sub' in user_columns:
|
if api_key:
|
||||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
|
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 conn.dialect.name == 'sqlite':
|
||||||
if oauth_sub:
|
_drop_sqlite_indexes_for_column('user', 'api_key', conn)
|
||||||
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
|
_drop_sqlite_indexes_for_column('user', 'oauth_sub', conn)
|
||||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth={provider: {'sub': sub}}))
|
|
||||||
|
|
||||||
# ── Migrate api_key column → api_key table (only if old column still exists)
|
with op.batch_alter_table('user') as batch_op:
|
||||||
if 'api_key' in user_columns:
|
batch_op.drop_column('api_key')
|
||||||
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
|
batch_op.drop_column('oauth_sub')
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
# --- 1. Restore old oauth_sub column ---
|
||||||
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
|
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
|
||||||
|
|
||||||
conn = op.get_bind()
|
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:
|
try:
|
||||||
data = oauth if isinstance(oauth, dict) else json.loads(oauth)
|
data = json.loads(oauth)
|
||||||
provider = list(data.keys())[0]
|
provider = list(data.keys())[0]
|
||||||
sub = data[provider].get('sub')
|
sub = data[provider].get('sub')
|
||||||
oauth_sub = f'{provider}@{sub}'
|
oauth_sub = f'{provider}@{sub}'
|
||||||
except Exception:
|
except Exception:
|
||||||
oauth_sub = None
|
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')
|
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))
|
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:
|
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')
|
op.drop_table('api_key')
|
||||||
|
|
||||||
with op.batch_alter_table('user') as batch_op:
|
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
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'b2c3d4e5f6a7'
|
revision: str = 'b2c3d4e5f6a7'
|
||||||
|
|
@ -19,12 +19,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ Create Date: 2026-04-01 04:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = 'b7c8d9e0f1a2'
|
revision = 'b7c8d9e0f1a2'
|
||||||
|
|
@ -17,14 +18,9 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
conn = op.get_bind()
|
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||||
inspector = sa.inspect(conn)
|
# Set existing chats to be marked as read
|
||||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
op.execute('UPDATE chat SET last_read_at = updated_at')
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
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! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
if 'hash' not in file_cols:
|
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
|
||||||
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
|
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
|
||||||
if 'data' not in file_cols:
|
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue