Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3660bc00fd | |||
| f51d2b026f | |||
| adc9076d17 | |||
| 8dae237a0b | |||
| 0a8a620fb6 | |||
| f31768e20e | |||
| 9bd84258d0 | |||
| 4d058a125b | |||
| e4e69a10ec | |||
| 6c159a97b7 | |||
| 947dcd34bd | |||
| 79f0437980 | |||
| 6137f7cb7e | |||
| 9c9a18d6d4 | |||
| 1ac3dd4a89 | |||
| 2ed3055c42 | |||
| b8112d72b9 | |||
| 4225791313 | |||
| 7c7fe44328 | |||
| 883f1dda0f | |||
| 7a7a25766c | |||
| 2b26355002 | |||
| f2a360cb87 | |||
| f9b0534e0c | |||
| 6adde203cd | |||
| a7271532f8 | |||
| d95f533214 | |||
| 6f1486ffd0 | |||
| 140605e660 | |||
| 9899293f05 | |||
| e3faec62c5 | |||
| fc05e0a6c5 | |||
| fe6783c166 |
+1
-10
@@ -19,13 +19,4 @@ FORWARDED_ALLOW_IPS='*'
|
||||
# DO NOT TRACK
|
||||
SCARF_NO_ANALYTICS=true
|
||||
DO_NOT_TRACK=true
|
||||
ANONYMIZED_TELEMETRY=false
|
||||
|
||||
# Valkey Vector Store (requires VECTOR_DB=valkey)
|
||||
# VALKEY_URL='valkey://localhost:6379'
|
||||
# VALKEY_COLLECTION_PREFIX='open_webui'
|
||||
# VALKEY_INDEX_TYPE='HNSW'
|
||||
# VALKEY_DISTANCE_METRIC='COSINE'
|
||||
# VALKEY_HNSW_M='16'
|
||||
# VALKEY_HNSW_EF_CONSTRUCTION='200'
|
||||
# VALKEY_HNSW_EF_RUNTIME='10'
|
||||
ANONYMIZED_TELEMETRY=false
|
||||
@@ -1,76 +1,82 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
description: Suggest an idea for this project
|
||||
title: 'feat: '
|
||||
labels: ['triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Before Submitting
|
||||
## Important Notes
|
||||
### Before submitting
|
||||
|
||||
Please check the **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) AND [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
|
||||
It's likely we're already tracking it! If you’re unsure, start a discussion post first.
|
||||
|
||||
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
|
||||
#### Scope
|
||||
|
||||
### Scope Guidelines
|
||||
If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
|
||||
**We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
|
||||
This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
|
||||
Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
|
||||
|
||||
If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
|
||||
|
||||
This will help us efficiently focus on improving the project.
|
||||
|
||||
### Collaborate respectfully
|
||||
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
|
||||
|
||||
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
|
||||
Remember:
|
||||
- Open WebUI is a **volunteer-driven project**
|
||||
- It's managed by a **single maintainer**
|
||||
- It's supported by contributors who also have **full-time jobs**
|
||||
|
||||
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
|
||||
|
||||
### Be Respectful
|
||||
|
||||
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
|
||||
We appreciate your time and ask that you **respect ours**.
|
||||
|
||||
### Contributing
|
||||
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
||||
|
||||
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
|
||||
|
||||
### Reproducibility
|
||||
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
|
||||
### Bug reproducibility
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
|
||||
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
attributes:
|
||||
label: Check Existing Issues
|
||||
description: Confirm you have searched for similar requests.
|
||||
description: Please confirm that you've checked for existing similar requests
|
||||
options:
|
||||
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
|
||||
- label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is comparable to my request.
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: feature-scope
|
||||
attributes:
|
||||
label: Verify Feature Scope
|
||||
description: Confirm this request belongs in Issues rather than Discussions.
|
||||
description: Please confirm the feature's scope is within the described scope
|
||||
options:
|
||||
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
|
||||
- label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the Discussions.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: Is this related to a problem? Describe the pain point clearly.
|
||||
placeholder: "e.g., I'm frustrated when..."
|
||||
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
|
||||
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe what you would like to happen.
|
||||
label: Desired Solution you'd like
|
||||
description: Clearly describe what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you have considered.
|
||||
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request.
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
|
||||
+5
-10
@@ -4,22 +4,17 @@ updates:
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: '/backend'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every week
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
target-branch: 'dev'
|
||||
|
||||
@@ -19,74 +19,75 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
|
||||
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** A concise description of the changes is provided below.
|
||||
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
|
||||
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
|
||||
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
|
||||
- [ ] **Testing:** Manual tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
|
||||
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
|
||||
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
|
||||
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
|
||||
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
|
||||
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
|
||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
||||
- **build**: Build system or dependency changes
|
||||
- **ci**: CI/CD workflow changes
|
||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
||||
- **docs**: Documentation additions or updates
|
||||
- **feat**: New features or enhancements
|
||||
- **fix**: Bug fixes or corrections
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
|
||||
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
|
||||
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
|
||||
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
|
||||
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
||||
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
|
||||
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
|
||||
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
||||
- **build**: Changes that affect the build system or external dependencies
|
||||
- **ci**: Changes to our continuous integration processes or workflows
|
||||
- **chore**: Refactor, cleanup, or other non-functional code changes
|
||||
- **docs**: Documentation update or addition
|
||||
- **feat**: Introduces a new feature or enhancement to the codebase
|
||||
- **fix**: Bug fix or error correction
|
||||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
- **style**: Formatting changes (whitespace, semicolons, etc.)
|
||||
- **test**: Test additions or corrections
|
||||
- **WIP**: Work in progress
|
||||
- **perf**: Performance improvement
|
||||
- **refactor**: Code restructuring for better maintainability, readability, or scalability
|
||||
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
|
||||
|
||||
# Changelog Entry
|
||||
|
||||
### Description
|
||||
|
||||
- [Describe the changes, including motivation and impact]
|
||||
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
|
||||
|
||||
### Added
|
||||
|
||||
- [New features, functionalities, or additions]
|
||||
- [List any new features, functionalities, or additions]
|
||||
|
||||
### Changed
|
||||
|
||||
- [Changes, updates, refactorings, or optimizations]
|
||||
- [List any changes, updates, refactorings, or optimizations]
|
||||
|
||||
### Deprecated
|
||||
|
||||
- [Deprecated functionality or features]
|
||||
- [List any deprecated functionality or features that have been removed]
|
||||
|
||||
### Removed
|
||||
|
||||
- [Removed features, files, or functionalities]
|
||||
- [List any removed features, files, or functionalities]
|
||||
|
||||
### Fixed
|
||||
|
||||
- [Bug fixes or corrections]
|
||||
- [List any fixes, corrections, or bug fixes]
|
||||
|
||||
### Security
|
||||
|
||||
- [Security-related changes or vulnerability fixes]
|
||||
- [List any new or updated security-related changes, including vulnerability fixes]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
|
||||
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
|
||||
|
||||
---
|
||||
|
||||
### Additional Information
|
||||
|
||||
- [Any additional context, notes, or references to related issues/commits]
|
||||
- [Insert any additional context, notes, or explanations for the changes]
|
||||
- [Reference any related issues, commits, or other relevant information]
|
||||
|
||||
### Screenshots or Videos
|
||||
|
||||
- [Attach relevant screenshots or videos demonstrating the changes]
|
||||
- [Attach any relevant screenshots or videos demonstrating the changes]
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
|
||||
@@ -1,40 +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']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths: ['backend/**', 'pyproject.toml', 'uv.lock']
|
||||
|
||||
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
|
||||
@@ -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 }}',
|
||||
})
|
||||
@@ -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 }}"
|
||||
@@ -1,331 +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: 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: .
|
||||
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 }}
|
||||
|
||||
copy-to-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
needs: [merge]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: main
|
||||
suffix: ""
|
||||
- variant: cuda
|
||||
suffix: "-cuda"
|
||||
- variant: cuda126
|
||||
suffix: "-cuda126"
|
||||
- variant: ollama
|
||||
suffix: "-ollama"
|
||||
- variant: slim
|
||||
suffix: "-slim"
|
||||
|
||||
steps:
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine source and destination tags
|
||||
id: tags
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="openwebui/open-webui"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
echo "tags=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
VARIANT_NAME="${SUFFIX#-}"
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Copy images from GHCR to Docker Hub
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
SOURCE_TAG="${VERSION}${SUFFIX}"
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
SOURCE_TAG="latest"
|
||||
else
|
||||
SOURCE_TAG="latest${SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
|
||||
|
||||
echo "Copying from ${SOURCE} to Docker Hub..."
|
||||
|
||||
while IFS= read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,63 +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
|
||||
|
||||
# ── 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
|
||||
@@ -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
|
||||
@@ -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 }}',
|
||||
})
|
||||
@@ -310,4 +310,3 @@ dist
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
.vscode/settings.json
|
||||
.cptr
|
||||
|
||||
-134
@@ -5,140 +5,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.6] - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- 📦 **Official knowledge base sync tool.** A new companion tool from Open WebUI, oikb, keeps a knowledge base in sync with a local directory, GitHub repo, S3 bucket, Confluence space, or any of more than 40 other sources, uploading only new and changed files using the incremental sync support added in this release. [oikb](https://github.com/open-webui/oikb)
|
||||
- 📂 **Smart directory sync for knowledge bases.** Local directories can now be synced into a knowledge base in one action: file checksums are compared against what's already stored, and only added or modified files are uploaded while removed files and orphaned subdirectories are cleaned up, with the directory structure mirrored automatically and per-file progress shown throughout. [#19190](https://github.com/open-webui/open-webui/issues/19190), [#19394](https://github.com/open-webui/open-webui/issues/19394), [Commit](https://github.com/open-webui/open-webui/commit/60c9db1cb81d021589cb49bee8744a799b51211f), [Commit](https://github.com/open-webui/open-webui/commit/73bdf86766d3f44467cdec436786fe089481baf3), [Commit](https://github.com/open-webui/open-webui/commit/9835b3f1dd7aa9c92ff08a634a0f97cc2a046e42), [Commit](https://github.com/open-webui/open-webui/commit/97252fa609440573251d8e75090f347ed1e51e1d), [Commit](https://github.com/open-webui/open-webui/commit/8f2d346e10c47b57bf6b5a6aa02d453488a88b89), [Commit](https://github.com/open-webui/open-webui/commit/1527eb6e01d441225c979d12d78b7625edf1086f)
|
||||
- 🗂️ **Knowledge base folders.** Files inside a knowledge base can now be organized into nested folders, with breadcrumb navigation that makes it much easier to manage and find content in large collections. [Commit](https://github.com/open-webui/open-webui/commit/c2cbc47ca76ebfa21e1d36279dbd859283cbbfb1), [Commit](https://github.com/open-webui/open-webui/commit/ab0ee858b73738c5b77d1d455e66e66bc61df1c4), [Commit](https://github.com/open-webui/open-webui/commit/2ad327a4dce64a8eedf5eb41c73b8520344fdd82), [Commit](https://github.com/open-webui/open-webui/commit/171150c1e12b5713f77a5ea0dd19c6581c7bef2e), [Commit](https://github.com/open-webui/open-webui/commit/e7d2ddbb1d14c03a52797751d24a98132aac0cd8), [Commit](https://github.com/open-webui/open-webui/commit/32a417bbf66ed28a5ca8a759ad3a87b2a2aa358a)
|
||||
- 🧰 **Filesystem tool for knowledge bases.** A new built-in tool, enabled via the "ENABLE_KB_EXEC" environment variable, lets AI models browse and search knowledge base contents using familiar filesystem commands such as 'ls', 'cat', 'grep', 'find', 'head', 'tail', and 'sed', including pipes between them. [Commit](https://github.com/open-webui/open-webui/commit/5b125c24d4eae925d3287efa626595bab29d5c33), [Commit](https://github.com/open-webui/open-webui/commit/ecec86dd32aa396dd5a383e9be5b0f16c4346c48), [Commit](https://github.com/open-webui/open-webui/commit/9ef579ce4be5b82f9281b085a621fed5b04e0d26), [Commit](https://github.com/open-webui/open-webui/commit/2f642754ac6b8c430228a7fc289357a6c6652235), [Commit](https://github.com/open-webui/open-webui/commit/3b00e5721a60518ea317c4fd61a6d0d182961a2f), [Commit](https://github.com/open-webui/open-webui/commit/4e78b355efe0611f27fddef591c69f7c1259a9f6), [Commit](https://github.com/open-webui/open-webui/commit/74f95a9b0d6b89b241701422170d54e21e701baa), [Commit](https://github.com/open-webui/open-webui/commit/cc16e06c32de48bdf464db5814483a49f3527b63), [Commit](https://github.com/open-webui/open-webui/commit/1ea54c3217f487990b6a4c0f0d8ea675a479b6e1)
|
||||
- ✏️ **File renaming in knowledge bases.** Files inside a knowledge base can now be renamed directly from the workspace, with the new name reflected wherever the file is referenced. [Commit](https://github.com/open-webui/open-webui/commit/3127f1b46255626ea92eb0b598e45078287303f1)
|
||||
- 😀 **Emoji picker in message input.** A new emoji button in the rich text formatting toolbar lets you browse and insert emojis directly into your messages. [#24704](https://github.com/open-webui/open-webui/pull/24704)
|
||||
- 🪄 **Per-chat skills toggle.** Skills can now be turned on or off for a conversation directly from the chat Integrations menu, the same way tools and capabilities already work, instead of only through the model preset. [#25036](https://github.com/open-webui/open-webui/issues/25036), [#25037](https://github.com/open-webui/open-webui/pull/25037)
|
||||
- 🔎 **Access preview for users and groups.** Administrators can now preview exactly which models, knowledge bases, and tools a given user or group can access, making it easier to audit and verify permission setups. [Commit](https://github.com/open-webui/open-webui/commit/9c14740ffb009d550dcbd5d6c599dac57053f112)
|
||||
- 📄 **Configurable knowledge base file page size.** Administrators can now request a larger page size when listing a knowledge base's files through the API, reducing the number of requests needed to retrieve large collections instead of paging through fixed increments of 30. [#25148](https://github.com/open-webui/open-webui/issues/25148), [Commit](https://github.com/open-webui/open-webui/commit/a4d1b3e9378a61a11dd9822a8dbb525f39753081)
|
||||
- 🔃 **Persistent processing indicator for knowledge files.** Files still being processed in a knowledge base now keep showing a processing indicator across page reloads, so you can tell what's still ingesting after navigating away and back. [#25031](https://github.com/open-webui/open-webui/issues/25031), [Commit](https://github.com/open-webui/open-webui/commit/ad9f2eeb15a448147d5e47e6a391cabb9aefd0ea)
|
||||
- 📑 **MinerU file type configuration.** Administrators can now configure which file types are processed by the MinerU document loader, via the new "MINERU_FILE_EXTENSIONS" setting, extending it beyond PDF to formats like DOCX, PPTX, and XLSX. [Commit](https://github.com/open-webui/open-webui/commit/d4030a8aa5d48c2a1cb06c461566844aca2530ab)
|
||||
- 📃 **Legacy Word document support.** Older ".doc" Word files can now have their text extracted by the default document extraction engine, in addition to the modern ".docx" format. [Commit](https://github.com/open-webui/open-webui/commit/9e3e24e304f8ff210494380b46f614a2984cafb3)
|
||||
- 📁 **Create subfolders from the folder header.** Chat folders can now have subfolders created directly from the folder header in the chat view, not just from the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/1f0948bcbef2af73b155535ea27762c522260afc)
|
||||
- ⚡ **Faster initial page loads.** The configuration endpoint that loads on every page visit no longer runs an unnecessary user-count query, making the initial application load lighter on the database, especially on instances with many users. [Commit](https://github.com/open-webui/open-webui/commit/0adc090dcbe636c9c9645d8ec7f6b89ea514870b)
|
||||
- 🚀 **Faster tool-enabled chat completions.** Chat completions that use multiple tools now start faster because the tools they reference are fetched from the database in a single batch query instead of one query per tool. [#24808](https://github.com/open-webui/open-webui/pull/24808), [Commit](https://github.com/open-webui/open-webui/commit/cc94a90b4d4d690bc7cb9f7124f2d6e552973970)
|
||||
- 🏎️ **More responsive web search under load.** Web search through SearXNG, Google PSE, Brave, Serper, and Serpstack now uses non-blocking network calls, so the server stays responsive to other users while a search is in flight, and concurrent multi-query searches complete faster. [Commit](https://github.com/open-webui/open-webui/commit/b94245d2ee191e8ef118bf9de1ff7539503bfec9)
|
||||
- 🐎 **Lighter Ollama backend connections.** Requests to Ollama backends now reuse a shared connection pool instead of opening a fresh session each time, reducing TCP and TLS handshake overhead for installs that poll Ollama frequently or have multiple backends configured. [Commit](https://github.com/open-webui/open-webui/commit/5d9a09a88a9094ebfcd249340be3aaee544b34d0)
|
||||
- 💽 **Fewer redundant model-list writes.** On multi-instance deployments backed by Redis, the model list is no longer rewritten when it hasn't changed, cutting a major source of redundant writes. [#25469](https://github.com/open-webui/open-webui/issues/25469), [#25474](https://github.com/open-webui/open-webui/pull/25474), [Commit](https://github.com/open-webui/open-webui/commit/fd76b51ab2ad4c4192f2c98153e47888712c2009)
|
||||
- 📉 **Faster websocket disconnect cleanup.** Disconnecting from a collaborative session no longer triggers a scan across the entire Redis keyspace, using a per-session index instead, which keeps disconnects cheap on large deployments. [#25466](https://github.com/open-webui/open-webui/issues/25466), [Commit](https://github.com/open-webui/open-webui/commit/c7de057a4a54cc80f366ad949417e78fae756d4c)
|
||||
- 📝 **Frontmatter auto-fill for tools, functions, and skills.** Opening a tool, function, or skill editor now auto-fills the name, id, and description fields from the file's frontmatter, saving you from re-entering metadata already declared in the source. [#24649](https://github.com/open-webui/open-webui/pull/24649), [Commit](https://github.com/open-webui/open-webui/commit/ef975649b26d3e7cd589c49be2fc77cee80fad8f)
|
||||
- 🪪 **More user placeholders in custom headers.** Custom-header templates for direct connections and tool servers now support "{{USER_EMAIL}}" and "{{USER_ROLE}}" alongside the existing user and session placeholders. [Commit](https://github.com/open-webui/open-webui/commit/ed73ef3d8df988b0e9646b82df5b1a453202ef8d)
|
||||
- ⏱️ **Configurable MCP connection timeout.** The timeout for the initial handshake with an MCP tool server is now configurable via the new "MCP_INITIALIZE_TIMEOUT" setting, so servers that are slow to start or expose many tools can finish connecting instead of timing out. [#25011](https://github.com/open-webui/open-webui/pull/25011), [Commit](https://github.com/open-webui/open-webui/commit/4297c02b121180e239a61c483ac8477cc557d4ef)
|
||||
- 📐 **Profile image size limit.** Administrators can now cap the size of inline profile images via the new "PROFILE_IMAGE_MAX_DATA_URI_SIZE" setting, bounding how much database and cache space inline avatars and model icons can consume. [#25468](https://github.com/open-webui/open-webui/issues/25468), [#25476](https://github.com/open-webui/open-webui/pull/25476)
|
||||
- 🎫 **Wildcard OAuth role mapping.** Administrators can now set "\*" in the allowed OAuth roles to grant the user role to any authenticated OAuth user, instead of having to enumerate every accepted role. [#25062](https://github.com/open-webui/open-webui/pull/25062), [Commit](https://github.com/open-webui/open-webui/commit/07cbc91a8eba3a9a3b39588b2ae5de916930af70)
|
||||
- 📊 **Paginated feedback history.** The feedback and evaluation history list is now paginated, keeping it responsive for instances that have accumulated large numbers of feedback entries. [Commit](https://github.com/open-webui/open-webui/commit/160a6694e4bd66fc42e6361947516e8e15414ef3)
|
||||
- 🔘 **Bulk enable or disable automations.** Automations can now be enabled or disabled in bulk from an actions menu on the automations page, instead of toggling each one individually. [Commit](https://github.com/open-webui/open-webui/commit/675e9bee5af8b9fb390fcb235a6c2c4766e78c75)
|
||||
- ➡️ **Optional auto-redirect to single sign-on.** Administrators can now enable "OAUTH_AUTO_REDIRECT" so that, on deployments with a single sign-on provider and no other login methods, users are sent straight to the provider instead of seeing a login page first. [#25067](https://github.com/open-webui/open-webui/pull/25067), [Commit](https://github.com/open-webui/open-webui/commit/d64ef1803d2f5cedb7b3a308151d08ff2cd2b8e1)
|
||||
- ☁️ **Azure AI Foundry v1 with Entra ID.** Open WebUI now supports Azure AI Foundry's OpenAI v1 endpoint together with Microsoft Entra ID authentication, so these connections work without manual workarounds. [#24761](https://github.com/open-webui/open-webui/issues/24761), [#24985](https://github.com/open-webui/open-webui/pull/24985), [Commit](https://github.com/open-webui/open-webui/commit/eb4eebc3ce1042cb0d393bf890c1895db6e08b19)
|
||||
- 🌎 **Linkup web search provider.** Administrators can now select Linkup as the web search provider from the admin settings, with options to configure the API key and search depth. [#24752](https://github.com/open-webui/open-webui/pull/24752), [Commit](https://github.com/open-webui/open-webui/commit/56c0d00e13c74d665124ec9f1cae78e83ca60b6a)
|
||||
- 🧊 **Valkey vector database support.** Valkey can now be used as the vector database backend, configurable through new "VALKEY_URL" and related settings including index type, distance metric, and HNSW tuning. [#24769](https://github.com/open-webui/open-webui/pull/24769), [Commit](https://github.com/open-webui/open-webui/commit/c0f1aa291938bbef62db8d4013dbc498b0abea15)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Spanish (Spain), Swedish, German, Korean, Catalan, Russian, Irish, Simplified Chinese, Traditional Chinese, Finnish, Polish, Turkish, and Malay were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section — some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
|
||||
- 🛡️ **Tool server permission enforcement.** The per-user permission for inline tool servers is now enforced on chat-completion requests, so users without that permission can no longer bypass the admin setting by supplying tool servers directly in their requests. [Commit](https://github.com/open-webui/open-webui/commit/5cc1eb517094e3507915664817285f1e6e37a16d)
|
||||
- 🔒 **Knowledge base access check in search tool.** The built-in knowledge search tool now verifies that the caller can access a knowledge base before searching it by id, preventing users from reading the contents of knowledge bases they have not been granted access to. [#25113](https://github.com/open-webui/open-webui/pull/25113)
|
||||
- 🗄️ **Cross-user access to retrieval collections.** Resolving the documents used for retrieval now verifies the caller's access to each referenced file and rejects client-supplied collection names, preventing a crafted request from pulling another user's files or vector collections into its context. [Commit](https://github.com/open-webui/open-webui/commit/ee47c9c833f8889f3abe99d2266c56e8f8d40230)
|
||||
- 🔣 **Collection name validation.** Vector collection names are now rejected unless they contain only safe characters, preventing malformed names from reaching the vector store or breaking out of a database query expression. [#24982](https://github.com/open-webui/open-webui/pull/24982)
|
||||
- 🚫 **Unscoped retrieval collections denied by default.** Retrieval requests for collection names that don't correspond to a known file, memory, web-search, or knowledge base are now denied for non-admins by default, with a new "ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS" setting to restore the previous behavior if needed. [Commit](https://github.com/open-webui/open-webui/commit/c93f071700520a60833b7f9616c292b11f210880)
|
||||
- 📜 **Prompt history authorization.** Comparing, deleting, and restoring prompt versions now verify the history entry belongs to the prompt you're authorized for, preventing access to or modification of another prompt's version history. [#25056](https://github.com/open-webui/open-webui/pull/25056)
|
||||
- 🚦 **Code interpreter permission on the legacy path.** The legacy code-execution path now enforces the same permission and capability checks as the current one, so users without the code interpreter permission can no longer trigger code execution through it. [#24724](https://github.com/open-webui/open-webui/pull/24724)
|
||||
- 🧱 **API key endpoint restriction bypass.** The endpoint allow-list that limits which paths an API key may reach is now matched against the routed request path directly, preventing a crafted request from slipping past the restriction. [#25123](https://github.com/open-webui/open-webui/pull/25123)
|
||||
- 🚧 **System prompt bypass via request parameter.** The flag that skips a model's configured system prompt can no longer be set by external clients through a request parameter, so admin-configured system prompts can't be bypassed from the API. [#25156](https://github.com/open-webui/open-webui/pull/25156)
|
||||
- 🚪 **Terminal proxy path traversal.** The terminal proxy now fully decodes request paths before validating them, blocking multi-encoded payloads that could otherwise escape the intended path. [#25157](https://github.com/open-webui/open-webui/pull/25157)
|
||||
- 🪤 **Cache file path traversal.** The cache file server now requires an exact directory boundary match, closing a gap where a sibling directory whose name began with the cache directory's name could be used to serve files from outside it. [#25086](https://github.com/open-webui/open-webui/pull/25086)
|
||||
- 🔀 **Ollama backend selection access check.** Requests can no longer target an arbitrary Ollama backend by index; a caller-supplied backend selector is now verified against the backends that actually serve the requested model. [Commit](https://github.com/open-webui/open-webui/commit/7139797be04030b9d1016782f1dbb251c5fe68bb)
|
||||
- 🔓 **Cross-user file exfiltration via image URLs.** When a chat message references a file by id in an "image_url" field, the server now resolves that file only for its owner, an administrator, or a user with an explicit read grant, preventing other authenticated users from extracting a file's contents by routing it through the model. [#24625](https://github.com/open-webui/open-webui/pull/24625), [Commit](https://github.com/open-webui/open-webui/commit/c75fe8e74b72617c51282cc3ea0a2e8d9cdd9140)
|
||||
- 📌 **Chat file attachment access checks.** Attaching files to a chat now links only files the caller can read, preventing a user from associating another user's file with their chat to access its contents. [#25054](https://github.com/open-webui/open-webui/pull/25054)
|
||||
- 🧾 **Model knowledge file ownership checks.** Creating or updating a model now verifies that any knowledge files attached to it are files the editor can access, preventing another user's files from being attached to a model. [#25055](https://github.com/open-webui/open-webui/pull/25055), [Commit](https://github.com/open-webui/open-webui/commit/27fb20c13a4bf8501f7a485abe0654eb5880980d)
|
||||
- 📅 **Calendar event move authorization.** Updating a calendar event to move it into a different calendar now requires write access on the destination calendar, preventing users from injecting events into calendars they cannot write to. [#24764](https://github.com/open-webui/open-webui/pull/24764)
|
||||
- 📣 **Channel chat access control.** Generating a response in a channel context now verifies the caller's access to that channel and scopes the included messages, preventing access to channels or messages the user isn't permitted to see. [#24725](https://github.com/open-webui/open-webui/pull/24725)
|
||||
- 🕸️ **Web loader SSRF gating with Playwright.** When the Playwright-based web loader is in use, page navigations and redirects are now validated the same way as the default loader, closing a gap where the Playwright path could reach internal or otherwise blocked URLs. [#24756](https://github.com/open-webui/open-webui/pull/24756)
|
||||
- 🛂 **DNS rebinding protection for URL fetches.** The IP address validated for an outbound URL fetch is now the same one used for the actual connection, closing a DNS rebinding window where an attacker-controlled hostname could resolve to a public IP during the safety check and then to a private IP when the connection was opened. [#24759](https://github.com/open-webui/open-webui/pull/24759)
|
||||
- 🪞 **OAuth profile picture redirect handling.** The OAuth profile picture fetch now follows redirects only when administrators have explicitly allowed it, closing a window where a redirect from an externally validated URL could be used to reach internal addresses. [#24809](https://github.com/open-webui/open-webui/pull/24809)
|
||||
- 🧼 **Model profile image script injection.** Model profile images are now validated on save and only served inline when they are a known-safe image type, preventing a crafted SVG profile image from running scripts in other users' browsers, while existing legacy images that fail validation are cleared gracefully instead of breaking the model list. [#25060](https://github.com/open-webui/open-webui/pull/25060), [#25173](https://github.com/open-webui/open-webui/pull/25173)
|
||||
- 🧯 **Diagram rendering script injection.** Mermaid diagrams rendered in chat are now sanitized before display, preventing a crafted diagram from running scripts in the viewer's browser. [#25219](https://github.com/open-webui/open-webui/pull/25219)
|
||||
- 🔐 **Shared-chat file write protection.** Access to a file through a shared chat now only grants read access, so users who can read a shared chat can no longer modify or delete files attached to it. [#24755](https://github.com/open-webui/open-webui/pull/24755)
|
||||
- 🔏 **Cross-origin embed prompt control.** When Open WebUI is embedded in an iframe on a different origin, the embedding page can now only drive the chat input or submit prompts if the user has explicitly opted in via the "iframe Sandbox Allow Same Origin" setting, preventing untrusted host pages from triggering confirmation dialogs or controlling the chat. [#24767](https://github.com/open-webui/open-webui/pull/24767), [Commit](https://github.com/open-webui/open-webui/commit/eb3076c1b02d90c2ce6e6d3beb08a37987c740ec)
|
||||
- 🗂️ **Chat folder ownership checks.** Creating a chat or updating a chat's folder now verifies the referenced folder belongs to the current user, preventing chats from being associated with folders owned by other people. [#24588](https://github.com/open-webui/open-webui/pull/24588)
|
||||
- 🧩 **Chat recovery from corrupted history.** Chats whose internal message graph was left in a malformed state by a failed regeneration now open and load correctly, with missing roles, parent references, and current-message pointers reconstructed automatically instead of breaking the chat. [#24424](https://github.com/open-webui/open-webui/issues/24424), [#24157](https://github.com/open-webui/open-webui/issues/24157), [#20474](https://github.com/open-webui/open-webui/issues/20474), [#24799](https://github.com/open-webui/open-webui/pull/24799), [Commit](https://github.com/open-webui/open-webui/commit/d310a0777c4c48ec772bdc9a510005d5e91b09c7)
|
||||
- 📨 **Imported chats with folders appear correctly.** Importing grouped chats no longer leaves them invisible when a referenced folder is missing; such chats now appear in the chat list instead of being silently orphaned. [#24910](https://github.com/open-webui/open-webui/issues/24910), [Commit](https://github.com/open-webui/open-webui/commit/7f7cd210186cb6a67e028c17974eb210fc7ba9fd)
|
||||
- 🎟️ **MCP tool server sessions stay connected.** OAuth-authenticated MCP tool server sessions are no longer mistakenly refreshed and deleted by the single sign-on session handler, so those connections stay active. [#24618](https://github.com/open-webui/open-webui/issues/24618), [Commit](https://github.com/open-webui/open-webui/commit/c8eb8edca4174ec68fabd071d6b08c0bc07f8117)
|
||||
- 🤝 **MCP OAuth scope discovery.** The OAuth flow for MCP tool servers now reads the scopes a server advertises through its Protected Resource Metadata, so connecting to servers that declare their own scopes succeeds. [#24730](https://github.com/open-webui/open-webui/issues/24730), [#24690](https://github.com/open-webui/open-webui/pull/24690)
|
||||
- 🔍 **Web search reliability.** Web search again fetches page content reliably with the default web loader engine, a new "USER_AGENT" environment variable lets administrators set a real browser user-agent so fetches aren't blocked by Cloudflare, Wikipedia, and other bot-detection systems, and the startup script no longer fails to launch when these new environment variables are unset. [#24560](https://github.com/open-webui/open-webui/issues/24560), [#24793](https://github.com/open-webui/open-webui/issues/24793), [#24683](https://github.com/open-webui/open-webui/pull/24683), [Commit](https://github.com/open-webui/open-webui/commit/f60733758272c4532cd032e518c9cd73f648043a)
|
||||
- 🔥 **Firecrawl web search results.** Web search using Firecrawl now returns results correctly regardless of which response format the Firecrawl version uses. [#24712](https://github.com/open-webui/open-webui/pull/24712)
|
||||
- 🦅 **Kagi web search.** Web search using Kagi works again after its API endpoint and request method were updated to match Kagi's current API. [#25015](https://github.com/open-webui/open-webui/pull/25015)
|
||||
- 🔢 **Bracketed numbers in code blocks.** Numbers in square brackets such as "[0]" inside code blocks are no longer stripped out as if they were source citations, so code displays and copies correctly. [#24948](https://github.com/open-webui/open-webui/issues/24948), [Commit](https://github.com/open-webui/open-webui/commit/e90a618f4555cea2c024bb60c1332ca04eed96da)
|
||||
- 🔌 **API chat completions reliability.** Direct calls to the chat completions API no longer fail with an internal error when no chat session identifier is supplied. [#24553](https://github.com/open-webui/open-webui/issues/24553), [#25235](https://github.com/open-webui/open-webui/issues/25235), [Commit](https://github.com/open-webui/open-webui/commit/bc244fdc90504824b76654880898bf3f6649c299), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc)
|
||||
- 🖼️ **ComfyUI image generation and editing.** Generating and editing images via a ComfyUI backend now works again, including when ComfyUI is hosted on a private or internal network where URL validation was previously blocking the admin-configured endpoint. [#24565](https://github.com/open-webui/open-webui/issues/24565), [Commit](https://github.com/open-webui/open-webui/commit/7dcd932ad7cb5feab007737c6e0281def5cd47fc), [Commit](https://github.com/open-webui/open-webui/commit/8aa2a42dc7887512e697ff85d044220945ced40f)
|
||||
- 🖌️ **Image generation with non-standard response headers.** Image generation now works with backends that return valid JSON without a standard content-type header, instead of rejecting the response. [#24838](https://github.com/open-webui/open-webui/pull/24838)
|
||||
- 🐘 **Knowledge search on large documents.** Searching knowledge bases on PostgreSQL no longer fails when scanning across documents with very large extracted text content. [#24670](https://github.com/open-webui/open-webui/issues/24670), [Commit](https://github.com/open-webui/open-webui/commit/d74ee34d9128295faa116c919bd1dcca77744975)
|
||||
- 💬 **Chat title generation.** Automatically generated chat titles now use the model currently selected in the dropdown for the active chat and fall back to the model from the active message branch otherwise, and a clear message is shown if no model is available instead of an unhelpful error. [#24604](https://github.com/open-webui/open-webui/issues/24604), [#24745](https://github.com/open-webui/open-webui/issues/24745), [Commit](https://github.com/open-webui/open-webui/commit/e5c8f8110a88739e011d8ab23e5cb11b4768469f), [Commit](https://github.com/open-webui/open-webui/commit/3c5e7968f0b5130890e17f1f2a2a52297cb145a6)
|
||||
- 🧮 **Message search and analytics consistency.** Edits, deletions, and branch changes made in a chat are now reflected in message search results and analytics counts instead of leaving stale entries behind. [#25205](https://github.com/open-webui/open-webui/pull/25205), [Commit](https://github.com/open-webui/open-webui/commit/aa06200f789a4c3fc54ea9a141cd347d76cda4c5)
|
||||
- 🩹 **Graceful handling of in-chat task failures.** When web search query generation, image prompt generation, or a tool call fails or references a missing tool, the chat now falls back or surfaces a clear error instead of breaking partway through the response. [#25038](https://github.com/open-webui/open-webui/issues/25038), [#25144](https://github.com/open-webui/open-webui/issues/25144), [Commit](https://github.com/open-webui/open-webui/commit/b64fd988f02b8a4295193892b24b00bdf635a4dc)
|
||||
- 🎛️ **Filter changes to message output.** Filter functions that modify a message's structured output after generation now have those changes saved and displayed, instead of being discarded when only the output, not the text content, was changed. [#24884](https://github.com/open-webui/open-webui/pull/24884)
|
||||
- ⏩ **Titles and tags reflect filtered output.** Outlet filters now run before automatic title, tag, and follow-up generation, so those are based on the final filtered message instead of the unfiltered version. [#24717](https://github.com/open-webui/open-webui/pull/24717)
|
||||
- 💾 **Action-replaced message content persists.** Message content replaced by an action function through its event emitter is now kept when the chat is saved, instead of reverting to the original after a page reload. [#24585](https://github.com/open-webui/open-webui/issues/24585), [#25485](https://github.com/open-webui/open-webui/pull/25485)
|
||||
- 🏷️ **Skill mentions in messages.** Mentioning a skill in a message now keeps the skill's name as readable text instead of removing it, and selecting a skill without typing anything no longer causes an error on providers that reject empty messages. [#24929](https://github.com/open-webui/open-webui/issues/24929), [Commit](https://github.com/open-webui/open-webui/commit/01810e32ad51305ca3247e8f83da95f6e6260f0c)
|
||||
- 🧹 **Usage timer cleanup on send failure.** The background usage-stats timer started during message generation is now always cleared, even when sending a message fails, preventing leaked timers from accumulating over a session. [#25478](https://github.com/open-webui/open-webui/pull/25478)
|
||||
- 🗑️ **Background tasks stop when a chat is removed.** Deleting or archiving a chat now cancels any in-flight generation or title and tag tasks for it, instead of leaving orphaned background work running. [#25050](https://github.com/open-webui/open-webui/pull/25050), [Commit](https://github.com/open-webui/open-webui/commit/778dba1d6b8dc3962163fa7bf9d802d9a07fba26)
|
||||
- ⌨️ **Responsive knowledge file search.** Searching for knowledge files in the chat picker and model knowledge selector now matches on file names by default instead of scanning the full extracted text of every document on each keystroke, keeping the search responsive on large deployments, with content search available as an explicit opt-in. [#25082](https://github.com/open-webui/open-webui/issues/25082), [#25119](https://github.com/open-webui/open-webui/pull/25119), [Commit](https://github.com/open-webui/open-webui/commit/591e0aafa1d5e21dcd9fa1279dada2076968b9cb)
|
||||
- 📥 **Document processing with empty embeddings.** Saving documents to the vector database no longer crashes when an embedding step returns no vectors, allowing the process to continue instead of failing the whole upload. [#25166](https://github.com/open-webui/open-webui/issues/25166)
|
||||
- 🔤 **Non-UTF-8 text and CSV uploads.** Text and CSV files saved in legacy encodings, including Latin-1, Windows-1252, and Chinese encodings such as GB18030, are now detected and loaded correctly instead of being rejected as binary or failing with an empty-content error. [#25172](https://github.com/open-webui/open-webui/issues/25172), [#24973](https://github.com/open-webui/open-webui/issues/24973), [Commit](https://github.com/open-webui/open-webui/commit/6f0277db52d005420480abb0702d421525d6ea8b), [Commit](https://github.com/open-webui/open-webui/commit/1bbb2b933d4cee70a5102eca4109e77b8625f8fc)
|
||||
- 🧽 **Null bytes in nested data no longer break saves.** Data containing null bytes nested inside structured fields is now sanitized correctly before being written, preventing database errors that the previous check failed to catch. [#25018](https://github.com/open-webui/open-webui/pull/25018), [Commit](https://github.com/open-webui/open-webui/commit/e3ab4bd212e44c39f439ec4ff5df7c3dbd046895)
|
||||
- 🧠 **Clear error when no embedding model is configured.** Using knowledge or retrieval features without a loaded embedding model now returns a clear setup error explaining what to configure, instead of failing with a cryptic crash. [Commit](https://github.com/open-webui/open-webui/commit/55ca719bbf76306ed647424c728f730d0bef21f9)
|
||||
- 🧲 **Memory search quality.** Memory searches now apply the configured embedding query prefix, so retrieval works correctly with embedding models that require one for queries. [#24921](https://github.com/open-webui/open-webui/pull/24921), [Commit](https://github.com/open-webui/open-webui/commit/ce4dca47cb19a6582fd8a550806c89ab297c038d)
|
||||
- 📚 **Knowledge tool context overflow.** The built-in tool that lists a model's knowledge no longer dumps every file in every knowledge base into the model's context; it now returns summaries by default and paginates file listings only for a requested knowledge base. [#25105](https://github.com/open-webui/open-webui/pull/25105), [Commit](https://github.com/open-webui/open-webui/commit/0e73f7af099e1f9437c55a5c2d16741c97dbd57f)
|
||||
- ⏳ **Terminal session stability.** The terminal proxy no longer hangs when one direction of the connection closes before the other, so terminal sessions shut down cleanly instead of stalling. [#25464](https://github.com/open-webui/open-webui/issues/25464), [#25479](https://github.com/open-webui/open-webui/pull/25479)
|
||||
- 🧷 **Tool call continuity with strict providers.** Chats that contain incomplete tool calls or orphaned tool results no longer fail to continue when sent to providers that strictly validate tool pairings, such as Anthropic and AWS Bedrock Converse. [#24758](https://github.com/open-webui/open-webui/issues/24758), [#24940](https://github.com/open-webui/open-webui/issues/24940), [#24798](https://github.com/open-webui/open-webui/pull/24798), [Commit](https://github.com/open-webui/open-webui/commit/cfa6908d579e1f7f202a321289029996130b8411)
|
||||
- 🛑 **Stream termination for pipe functions.** Streamed responses from pipe functions now always send the standard end-of-stream marker, so chat clients and external integrations reliably detect when a response is complete instead of waiting on streams that already finished. [#24763](https://github.com/open-webui/open-webui/pull/24763)
|
||||
- 🔊 **Non-blocking text-to-speech transcoding.** Converting text-to-speech audio to MP3 no longer blocks the server's event loop, so other requests stay responsive even while a TTS response is being transcoded. [#24876](https://github.com/open-webui/open-webui/pull/24876)
|
||||
- 🎚️ **Default text-to-speech voice.** Text-to-speech requests now honor the voice specified in the request and fall back to the configured default only when none is given, instead of always using the admin default or failing. [#15143](https://github.com/open-webui/open-webui/issues/15143), [#25035](https://github.com/open-webui/open-webui/issues/25035), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc), [Commit](https://github.com/open-webui/open-webui/commit/750604a11d4adcb5ae568b9fc93010031ad394f9)
|
||||
- 🪝 **Reliable knowledge base file linking.** Files uploaded to a knowledge collection are now linked on the server as part of the upload itself, so they remain attached to the collection even if you navigate away or close the page before processing finishes. [#24807](https://github.com/open-webui/open-webui/issues/24807), [Commit](https://github.com/open-webui/open-webui/commit/d0b17f056911ec73df2b686a0d92bb789391db27)
|
||||
- ☁️ **Azure connections on custom hostnames.** Connections marked as the Azure provider now use the Azure code path even when the endpoint does not contain "azure" in its hostname, fixing custom Azure deployments served from non-standard domains. [#24882](https://github.com/open-webui/open-webui/pull/24882), [Commit](https://github.com/open-webui/open-webui/commit/c8f851bd2de3d127f1e818fc116c27174f96d3f1)
|
||||
- 🗓️ **Clearing calendar event fields.** Removing the description or location from a calendar event now saves correctly instead of silently keeping the previous value. [#25026](https://github.com/open-webui/open-webui/issues/25026), [Commit](https://github.com/open-webui/open-webui/commit/91810f1c4e93d4f559bdf61cd085cadc288a732d), [Commit](https://github.com/open-webui/open-webui/commit/78b1637a035d71099262412e5dee3e4d65c7fb2f)
|
||||
- 💭 **Advanced parameter settings.** Custom reasoning tags and custom model parameters are now saved correctly instead of being dropped, and the presence penalty and repeat penalty no longer save the frequency penalty's value instead of their own. [#25183](https://github.com/open-webui/open-webui/pull/25183), [#25200](https://github.com/open-webui/open-webui/pull/25200), [#25204](https://github.com/open-webui/open-webui/pull/25204)
|
||||
- 📏 **Long username display.** Long usernames no longer overflow their containers in the admin user list, user modals, and sidebar. [#25185](https://github.com/open-webui/open-webui/pull/25185)
|
||||
- 🎯 **All skills selectable in the model editor.** The model editor's skills selector now lists every skill you have access to, with a search box for large lists, instead of showing only the first 30 with no way to reach the rest. [#24873](https://github.com/open-webui/open-webui/issues/24873), [Commit](https://github.com/open-webui/open-webui/commit/936d5f2676dfbd2ba763b30af33a8557dcda9b05)
|
||||
- 🔔 **Accurate knowledge upload feedback.** Dragging files into a knowledge base no longer shows an upload notification before the upload has actually been processed. [#25484](https://github.com/open-webui/open-webui/pull/25484)
|
||||
- ♿ **High-contrast timestamp readability.** The user message timestamp now uses the correct colors in high-contrast mode instead of inverted ones, keeping it readable. [#25461](https://github.com/open-webui/open-webui/pull/25461)
|
||||
- ♿ **Keyboard and screen reader access to menus.** The integrations, more-options, and user menus are now real buttons with labels and keyboard support, so they can be opened with the keyboard and announced by screen readers. [Commit](https://github.com/open-webui/open-webui/commit/346dab3d8f909fc321a49ea2be633ea5c4c4a349)
|
||||
- 🖱️ **Focus-loss handling in editors.** Workspace and admin editors for models, tools, functions, and skills again respond correctly when the browser window loses focus, after the wrong event name was being listened for. [#25459](https://github.com/open-webui/open-webui/pull/25459)
|
||||
- 🛟 **Resilience to corrupted local storage.** Corrupted data in the browser's local storage no longer crashes the interface; affected settings and dismissed-banner state now fall back to safe defaults. [#25481](https://github.com/open-webui/open-webui/pull/25481)
|
||||
- 📶 **Quieter reconnection notifications.** Brief connection interruptions, such as backgrounding a mobile tab, no longer flash a "connection lost" warning, and the "reconnected" message only appears if a disconnect was actually shown. [Commit](https://github.com/open-webui/open-webui/commit/77c8c54b1ea07189e21313f9cd401f3b956bb93a)
|
||||
- 🍎 **Safari PDF handling.** PDF processing now works in Safari, which doesn't support the stream iteration the previous code relied on. [#25151](https://github.com/open-webui/open-webui/issues/25151), [#25473](https://github.com/open-webui/open-webui/pull/25473)
|
||||
- 🎙️ **Voice mode mute shortcut listing.** The keyboard shortcut for muting voice mode now appears in the keyboard shortcuts help modal. [#25193](https://github.com/open-webui/open-webui/pull/25193)
|
||||
- 📎 **Document attachments in channel model replies.** Tagging a model in a channel thread now forwards uploaded non-image documents such as PDFs and DOCX files into the model's context, so document summarization and comparison workflows that already worked in direct chat now work in channels too. [#24896](https://github.com/open-webui/open-webui/issues/24896), [#24898](https://github.com/open-webui/open-webui/pull/24898), [Commit](https://github.com/open-webui/open-webui/commit/7e9d41d664d7065a92ac93606d18e88c45eb36b6)
|
||||
- 🙈 **Hidden models in channel mentions.** Models marked as hidden no longer appear in the channel message-input model mention selector, matching how hidden models are excluded elsewhere in the interface. [#24892](https://github.com/open-webui/open-webui/pull/24892)
|
||||
- 🧵 **Channel thread and pinned message stability.** Opening a channel thread or the pinned messages view no longer fails to render when a message or its data is missing. [#25209](https://github.com/open-webui/open-webui/pull/25209)
|
||||
- 📺 **YouTube short link transcripts.** Pasting a "youtu.be" short link into a chat now loads the video transcript correctly instead of failing with an empty-content error. [#24856](https://github.com/open-webui/open-webui/issues/24856), [Commit](https://github.com/open-webui/open-webui/commit/1e36a206008c3abce5ccdc98d5750e30a7345b98)
|
||||
- 🙉 **Hidden models in default-model and automation pickers.** The admin pickers for default models and default pinned models, and the automation model dropdown, now filter out hidden models, consistent with how hidden models are treated elsewhere. [#24869](https://github.com/open-webui/open-webui/issues/24869), [Commit](https://github.com/open-webui/open-webui/commit/1fa3050f069a72de1daaaa29233b545c4deaea51), [Commit](https://github.com/open-webui/open-webui/commit/4705c2d98812c1189c2ae4960399bc8888d4861c)
|
||||
- 🔊 **Speech-to-text SSL setting honored.** Speech-to-text requests now respect the "AIOHTTP_CLIENT_SESSION_SSL" setting, so administrators using self-signed certificates or custom SSL configurations can use STT engines that were previously failing TLS verification. [#24568](https://github.com/open-webui/open-webui/issues/24568), [#24857](https://github.com/open-webui/open-webui/pull/24857), [Commit](https://github.com/open-webui/open-webui/commit/2ca91ceeeca6a6a21e5a6ad68ea3ab8c9d9f6deb), [Commit](https://github.com/open-webui/open-webui/commit/94b66b17972e0ad77954df4b81a6bb86a7a6a04b)
|
||||
- 🔗 **Placeholders in MCP connection headers.** Custom header templates configured on MCP server connections now have their "{{USER_ID}}", "{{USER_NAME}}", "{{USER_EMAIL}}", "{{USER_ROLE}}", "{{CHAT_ID}}", and "{{MESSAGE_ID}}" placeholders interpolated at request time, matching how custom headers already work for direct connections and tool servers. [#24822](https://github.com/open-webui/open-webui/pull/24822)
|
||||
- 🪟 **Bing search CLI smoke test.** Running the Bing web-search module from the command line for a quick connectivity check no longer raises an error about missing arguments. [#24765](https://github.com/open-webui/open-webui/issues/24765), [#24768](https://github.com/open-webui/open-webui/pull/24768)
|
||||
- 🩺 **Database health check recovery.** After a transient database connection error, the health check endpoint now recovers automatically instead of staying permanently broken on the affected worker. [Commit](https://github.com/open-webui/open-webui/commit/0b81520e072bb4ed15532b8e153b43dd3243feaf)
|
||||
- 🥾 **Startup on non-Unicode consoles.** Open WebUI no longer crashes at startup when the console can't encode the banner's box-drawing characters, such as on Windows or with redirected or headless output, falling back to a plain-text banner instead. [#24965](https://github.com/open-webui/open-webui/issues/24965), [#25482](https://github.com/open-webui/open-webui/pull/25482)
|
||||
- 🆕 **First admin signup after a reset.** Creating the first administrator account is no longer blocked by a previously stored signup setting, so a fresh or reset instance can always be bootstrapped. [#24821](https://github.com/open-webui/open-webui/pull/24821)
|
||||
- 🪵 **JSON exception logging.** With JSON log formatting enabled, exceptions are now recorded correctly with a structured type, message, and stacktrace instead of being dropped, and a logging failure can no longer crash the application. [#25135](https://github.com/open-webui/open-webui/issues/25135), [Commit](https://github.com/open-webui/open-webui/commit/79bf3d28d88e78b0136ef3c3c9f8e7bb85d3cea9)
|
||||
- 🧭 **Workspace skills permission.** Users granted only the "workspace.skills" permission can now see the workspace entry in the sidebar and are correctly routed to the skills page from the workspace index. [#24729](https://github.com/open-webui/open-webui/pull/24729)
|
||||
- 🔁 **Resilient database migrations.** Database migrations now skip tables, indexes, and columns that already exist and add missing primary keys to legacy tables, so upgrades succeed even when parts of the schema were manually or partially created beforehand. [Commit](https://github.com/open-webui/open-webui/commit/81f611fb73c726cfcfc20ee57a926a543f07e95f), [Commit](https://github.com/open-webui/open-webui/commit/f0e88dadc8502ea05b1a00f3155bee7d1cf32249), [Commit](https://github.com/open-webui/open-webui/commit/bd9f82d5a681ee94bde44325033447500a0c76bf), [Commit](https://github.com/open-webui/open-webui/commit/459b1c3fda2ec3579fbd2ab408d0fdeb07c96b99), [Commit](https://github.com/open-webui/open-webui/commit/6df09a4039d181f4e2d41324e93cd36c6fb27dfd), [Commit](https://github.com/open-webui/open-webui/commit/95840e307a66429168775de389b329487a4311c4), [Commit](https://github.com/open-webui/open-webui/commit/6b1df94bf933af92f5c7d093a2d92e2e50d6fdd0), [Commit](https://github.com/open-webui/open-webui/commit/98d3b2308564e2112290773ea240b90419ebb48d), [Commit](https://github.com/open-webui/open-webui/commit/1b9d22e324181b96511a15cbacc40fdbb439ad79), [Commit](https://github.com/open-webui/open-webui/commit/dc0f8ae6f2372b6e5dd42f3520813b09c54146c0), [Commit](https://github.com/open-webui/open-webui/commit/ee3b14233a642ff1bc38aef03a2418afcf5d6c1d), [Commit](https://github.com/open-webui/open-webui/commit/1004dad2749bcc369d511315a808d4bb5277854c), [Commit](https://github.com/open-webui/open-webui/commit/db2b3d7fd86cc7d76424671ead3c62a3f6302fe7), [Commit](https://github.com/open-webui/open-webui/commit/2e1b671e8db49f47d69fd59ece2b58e109ec8b84), [Commit](https://github.com/open-webui/open-webui/commit/9a8969ca93f8c9109d956518a085be3e2afacc07), [Commit](https://github.com/open-webui/open-webui/commit/9717ada92fdb3761a217d309f322ee9fbe418d0d), [Commit](https://github.com/open-webui/open-webui/commit/d7cfc1e46a8f3e5c8f6758c12ef641f41fb51a39), [Commit](https://github.com/open-webui/open-webui/commit/9263b7568eaa83f43e4a118c98490c4f7aaba570), [Commit](https://github.com/open-webui/open-webui/commit/73d2065227e651cf90a2cfade721516815743ab4), [#24722](https://github.com/open-webui/open-webui/pull/24722)
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
|
||||
- ⚙️ **Tool-call iteration cap renamed and raised.** The environment variable that limits how many tool calls a single chat response may make is now "CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS", with its default raised from 30 to 256 and a new "-1" value for unlimited; the previous "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" name continues to work as a fallback, and chats that hit the cap now show a clear error in-chat instead of stopping silently. [#24918](https://github.com/open-webui/open-webui/pull/24918), [Commit](https://github.com/open-webui/open-webui/commit/2b99945d2726bdf5aed7b1712f9e3b7b622671df)
|
||||
- 🔐 **Reduced public "/api/config" exposure.** The "/api/config" response no longer includes several feature flags ("enable_api_keys", "enable_password_change_form", "enable_version_update_check", "enable_public_active_users_count", "enable_easter_eggs") for unauthenticated callers, reducing information disclosure to anonymous visitors. [Commit](https://github.com/open-webui/open-webui/commit/245e0ee029e9e10617f62953a9e6f67dd00ecf81), [Commit](https://github.com/open-webui/open-webui/commit/ae06e199d5d3f65296a978cc35079bdacba596d2)
|
||||
- 🔑 **"WEBUI_SECRET_KEY" is now a hard requirement even for unsupported deployments.** Deployments that start the backend in an explicitly unsupported way (such as invoking uvicorn directly) without setting "WEBUI_SECRET_KEY" will now refuse to start instead of falling back to an empty key; the supported start methods (start.sh, start_windows.bat, and "open-webui serve") still set or auto-generate it automatically, so standard deployments are unaffected. Direct Uvicorn startup is not supported. [#25218](https://github.com/open-webui/open-webui/pull/25218)
|
||||
|
||||
## [0.9.5] - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,84 +27,58 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
||||
|
||||
## Key Features of Open WebUI ⭐
|
||||
|
||||
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
|
||||
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
|
||||
|
||||
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
|
||||
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
|
||||
|
||||
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
|
||||
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
|
||||
|
||||
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
|
||||
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
|
||||
|
||||
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
|
||||
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
|
||||
|
||||
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
|
||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||
|
||||
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
|
||||
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
|
||||
|
||||
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
|
||||
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
|
||||
|
||||
- ✅ **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
|
||||
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
|
||||
|
||||
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
|
||||
|
||||
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
|
||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, 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.
|
||||
|
||||
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
|
||||
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
|
||||
|
||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
|
||||
|
||||
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
|
||||
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
|
||||
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
|
||||
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
|
||||
|
||||
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
|
||||
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
|
||||
|
||||
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
|
||||
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
|
||||
|
||||
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
|
||||
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
|
||||
|
||||
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
|
||||
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
|
||||
|
||||
- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
|
||||
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
|
||||
|
||||
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
|
||||
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
|
||||
|
||||
- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
|
||||
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
|
||||
|
||||
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
|
||||
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
|
||||
|
||||
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
|
||||
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
|
||||
|
||||
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
|
||||
|
||||
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
|
||||
|
||||
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
|
||||
|
||||
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
|
||||
|
||||
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
|
||||
|
||||
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
|
||||
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
|
||||
|
||||
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
|
||||
|
||||
## The Open WebUI Ecosystem 🌐
|
||||
|
||||
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
|
||||
|
||||
- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): 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** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 💻 **cptr** ([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.
|
||||
|
||||
- 🔄 **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!
|
||||
@@ -248,10 +222,6 @@ This project contains code under multiple licenses. The current codebase include
|
||||
If you have any questions, suggestions, or need assistance, please open an issue or join our
|
||||
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
|
||||
|
||||
## Security 🛡️
|
||||
|
||||
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#open-webui/open-webui&Date">
|
||||
|
||||
@@ -11,7 +11,6 @@ import uvicorn
|
||||
app = typer.Typer()
|
||||
|
||||
KEY_FILE = Path.cwd() / '.webui_secret_key'
|
||||
DEFAULT_SECRET_KEY_LENGTH = 24
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -38,11 +37,8 @@ def serve(
|
||||
if os.getenv('WEBUI_SECRET_KEY') is None:
|
||||
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
|
||||
if not KEY_FILE.exists():
|
||||
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
|
||||
if key_length < 1:
|
||||
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
|
||||
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
|
||||
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
|
||||
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
|
||||
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
|
||||
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
|
||||
|
||||
|
||||
+3204
-1923
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@@ -74,11 +72,11 @@ class ERROR_MESSAGES(str, Enum):
|
||||
|
||||
EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.'
|
||||
|
||||
DB_NOT_SQLITE = 'This feature is only available with SQLite databases.'
|
||||
DB_NOT_SQLITE = 'This feature is only available when running with SQLite databases.'
|
||||
|
||||
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
|
||||
INVALID_URL = 'Oops! The URL you provided is invalid. Please double-check and try again.'
|
||||
|
||||
WEB_SEARCH_ERROR = lambda err='': err if err else '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.'
|
||||
|
||||
|
||||
+521
-504
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import AsyncGenerator, Generator, Iterator
|
||||
import inspect
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import AsyncGenerator, Generator, Iterator
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
@@ -15,35 +16,40 @@ from fastapi import (
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import (
|
||||
get_event_call,
|
||||
get_event_emitter,
|
||||
)
|
||||
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.access_control import check_model_access
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
from open_webui.utils.misc import (
|
||||
add_or_update_system_message,
|
||||
get_last_user_message,
|
||||
prepend_to_first_user_message_content,
|
||||
openai_chat_chunk_message_template,
|
||||
openai_chat_completion_message_template,
|
||||
prepend_to_first_user_message_content,
|
||||
)
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_openai,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.plugin import (
|
||||
get_function_module_from_cache,
|
||||
load_function_module_by_id,
|
||||
)
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -318,10 +324,11 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
||||
async for line in res:
|
||||
yield process_line(form_data, line)
|
||||
|
||||
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
|
||||
finish_message['choices'][0]['finish_reason'] = 'stop'
|
||||
yield f'data: {json.dumps(finish_message)}\n\n'
|
||||
yield 'data: [DONE]'
|
||||
if isinstance(res, str) or isinstance(res, Generator):
|
||||
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
|
||||
finish_message['choices'][0]['finish_reason'] = 'stop'
|
||||
yield f'data: {json.dumps(finish_message)}\n\n'
|
||||
yield 'data: [DONE]'
|
||||
|
||||
return StreamingResponse(stream_content(), media_type='text/event-stream')
|
||||
else:
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from open_webui.internal.wrappers import register_connection
|
||||
from open_webui.env import (
|
||||
DATABASE_ENABLE_SESSION_SHARING,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
OPEN_WEBUI_DIR,
|
||||
DATABASE_URL,
|
||||
DATABASE_SCHEMA,
|
||||
DATABASE_POOL_MAX_OVERFLOW,
|
||||
DATABASE_POOL_RECYCLE,
|
||||
DATABASE_POOL_SIZE,
|
||||
DATABASE_POOL_TIMEOUT,
|
||||
DATABASE_SCHEMA,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
DATABASE_ENABLE_SESSION_SHARING,
|
||||
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
|
||||
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
|
||||
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
|
||||
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
|
||||
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
|
||||
DATABASE_URL,
|
||||
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
|
||||
ENABLE_DB_MIGRATIONS,
|
||||
OPEN_WEBUI_DIR,
|
||||
)
|
||||
from sqlalchemy import Dialect, MetaData, create_engine, event, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from peewee_migrate import Router
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, event, types
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker, Session
|
||||
from sqlalchemy.pool import QueuePool, NullPool
|
||||
from sqlalchemy.sql.type_api import _T
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -117,25 +117,61 @@ extract_ssl_mode_from_url = extract_ssl_params_from_url
|
||||
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
|
||||
|
||||
|
||||
class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
|
||||
"""Store arbitrary Python objects as JSON-encoded TEXT.
|
||||
|
||||
Used instead of native JSON columns for portability across SQLite and
|
||||
PostgreSQL. Values are serialized with ``json.dumps`` on write and
|
||||
deserialized with ``json.loads`` on read.
|
||||
"""
|
||||
|
||||
impl = types.UnicodeText
|
||||
class JSONField(types.TypeDecorator):
|
||||
impl = types.Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return json.dumps(value) if value is not None else None
|
||||
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||
return json.dumps(value)
|
||||
|
||||
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return json.loads(value) if value is not None else None
|
||||
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
|
||||
def copy(self, **kwargs: Any) -> Self:
|
||||
return JSONField(length=self.impl.length)
|
||||
def copy(self, **kw: Any) -> Self:
|
||||
return JSONField(self.impl.length)
|
||||
|
||||
def db_value(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
def python_value(self, value):
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
# Workaround to handle the peewee migration
|
||||
# This is required to ensure the peewee migration is handled before the alembic migration
|
||||
def handle_peewee_migration(DATABASE_URL):
|
||||
db = None
|
||||
try:
|
||||
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`)
|
||||
# and cert-file params are preserved in the connection string.
|
||||
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
|
||||
@@ -374,7 +410,7 @@ async def get_async_db():
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_async_db_context(db: AsyncSession | None = None):
|
||||
async def get_async_db_context(db: Optional[AsyncSession] = None):
|
||||
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
|
||||
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
|
||||
yield db
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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))
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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
|
||||
+1147
-600
File diff suppressed because it is too large
Load Diff
@@ -1,84 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Alembic environment configuration runner.
|
||||
# Coordinates database migrations in both offline and online execution modes.
|
||||
import logging.config
|
||||
import logging
|
||||
import alembic.context
|
||||
from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
|
||||
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from open_webui.models.auths import Auth
|
||||
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
||||
from sqlalchemy import create_engine, engine_from_config, pool
|
||||
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
|
||||
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||
from sqlalchemy import engine_from_config, pool, create_engine
|
||||
|
||||
alembic_config = alembic.context.config
|
||||
if alembic_config.config_file_name:
|
||||
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
# Re-apply JSON formatter after fileConfig replaces handlers.
|
||||
if LOG_FORMAT == 'json':
|
||||
from open_webui.env import JSONFormatter
|
||||
|
||||
for log_handler in logging.root.handlers:
|
||||
log_handler.setFormatter(JSONFormatter())
|
||||
migration_metadata = Auth.metadata
|
||||
target_db_url = DATABASE_URL
|
||||
base_url, ssl_query_params = extract_ssl_params_from_url(target_db_url)
|
||||
if ssl_query_params:
|
||||
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
|
||||
if target_db_url:
|
||||
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
|
||||
for handler in logging.root.handlers:
|
||||
handler.setFormatter(JSONFormatter())
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Auth.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
DB_URL = DATABASE_URL
|
||||
|
||||
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2 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:
|
||||
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
|
||||
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
|
||||
alembic.context.configure(
|
||||
url=db_connection_url,
|
||||
target_metadata=migration_metadata,
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
with alembic.context.begin_transaction():
|
||||
alembic.context.run_migrations()
|
||||
|
||||
|
||||
def _get_engine_connectable():
|
||||
"""Build the database engine based on target URL and authentication credentials."""
|
||||
if target_db_url and target_db_url.startswith('sqlite+sqlcipher://'):
|
||||
if not DATABASE_PASSWORD or not DATABASE_PASSWORD.strip():
|
||||
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
||||
raw_db_path = target_db_url.replace('sqlite+sqlcipher://', '')
|
||||
if raw_db_path.startswith('/'):
|
||||
raw_db_path = raw_db_path[1:]
|
||||
|
||||
def _sqlite_cipher_creator():
|
||||
import sqlcipher3
|
||||
|
||||
cipher_conn = sqlcipher3.connect(raw_db_path, check_same_thread=False)
|
||||
cipher_conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
|
||||
return cipher_conn
|
||||
|
||||
return create_engine('sqlite://', creator=_sqlite_cipher_creator, echo=False)
|
||||
return engine_from_config(
|
||||
alembic_config.get_section(alembic_config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Execute migrations against a live database connection."""
|
||||
live_connectable = _get_engine_connectable()
|
||||
with live_connectable.connect() as live_connection:
|
||||
alembic.context.configure(
|
||||
connection=live_connection,
|
||||
target_metadata=migration_metadata,
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# Handle SQLCipher URLs
|
||||
if DB_URL and DB_URL.startswith('sqlite+sqlcipher://'):
|
||||
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == '':
|
||||
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
||||
|
||||
# Extract database path from SQLCipher URL
|
||||
db_path = DB_URL.replace('sqlite+sqlcipher://', '')
|
||||
if db_path.startswith('/'):
|
||||
db_path = db_path[1:] # Remove leading slash for relative paths
|
||||
|
||||
# Create a custom creator function that uses sqlcipher3
|
||||
def create_sqlcipher_connection():
|
||||
import sqlcipher3
|
||||
|
||||
conn = sqlcipher3.connect(db_path, check_same_thread=False)
|
||||
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
|
||||
return conn
|
||||
|
||||
connectable = create_engine(
|
||||
'sqlite://', # Dummy URL since we're using creator
|
||||
creator=create_sqlcipher_connection,
|
||||
echo=False,
|
||||
)
|
||||
with alembic.context.begin_transaction():
|
||||
alembic.context.run_migrations()
|
||||
else:
|
||||
# Standard database connection (existing logic)
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
# Alembic execution entrypoint branch
|
||||
if alembic.context.is_offline_mode():
|
||||
run_migrations_offline() # run in offline mode
|
||||
if not alembic.context.is_offline_mode():
|
||||
run_migrations_online() # run in online mode
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Alembic migration utilities."""
|
||||
|
||||
from alembic import op # noqa: E402 — alembic runtime context
|
||||
from sqlalchemy import inspect # metadata inspection
|
||||
from alembic import op
|
||||
from sqlalchemy import Inspector
|
||||
|
||||
|
||||
# --- database helper functions ---
|
||||
def get_existing_tables() -> set[str]:
|
||||
"""Return table names already present in the database."""
|
||||
conn = op.get_bind()
|
||||
return set(inspect(conn).get_table_names())
|
||||
def get_existing_tables():
|
||||
con = op.get_bind()
|
||||
inspector = Inspector.from_engine(con)
|
||||
tables = set(inspector.get_table_names())
|
||||
return tables
|
||||
|
||||
|
||||
def get_revision_id() -> str:
|
||||
"""Generate a short random revision identifier."""
|
||||
def get_revision_id():
|
||||
import uuid
|
||||
|
||||
return uuid.uuid4().hex[:12]
|
||||
return str(uuid.uuid4()).replace('-', '')[:12]
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-08-13 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '018012973d35'
|
||||
down_revision = 'd31026856c01'
|
||||
@@ -16,34 +16,18 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
def _idx_exists(table, idx_name):
|
||||
return any(i['name'] == idx_name for i in inspector.get_indexes(table))
|
||||
|
||||
# Chat table indexes
|
||||
if not _idx_exists('chat', 'folder_id_idx'):
|
||||
op.create_index('folder_id_idx', 'chat', ['folder_id'])
|
||||
if not _idx_exists('chat', 'user_id_pinned_idx'):
|
||||
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
|
||||
if not _idx_exists('chat', 'user_id_archived_idx'):
|
||||
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
|
||||
if not _idx_exists('chat', 'updated_at_user_id_idx'):
|
||||
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
|
||||
if not _idx_exists('chat', 'folder_id_user_id_idx'):
|
||||
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
|
||||
op.create_index('folder_id_idx', 'chat', ['folder_id'])
|
||||
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
|
||||
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
|
||||
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
|
||||
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
|
||||
|
||||
# Tag table index
|
||||
if not _idx_exists('tag', 'user_id_idx'):
|
||||
op.create_index('user_id_idx', 'tag', ['user_id'])
|
||||
op.create_index('user_id_idx', 'tag', ['user_id'])
|
||||
|
||||
# Function table index (only if is_global column exists — added by a later migration)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
func_cols = {c['name'] for c in inspector.get_columns('function')}
|
||||
if 'is_global' in func_cols and not _idx_exists('function', 'is_global_idx'):
|
||||
op.create_index('is_global_idx', 'function', ['is_global'])
|
||||
# Function table index
|
||||
op.create_index('is_global_idx', 'function', ['is_global'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update, column
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.sql import column, select, table, update
|
||||
|
||||
import json
|
||||
|
||||
revision = '1af9b942657b'
|
||||
down_revision = '242a2047eae0'
|
||||
@@ -93,11 +93,8 @@ def upgrade():
|
||||
conn.execute(update_stmt)
|
||||
|
||||
# Add columns `pinned` and `meta` to 'chat'
|
||||
chat_columns = {c['name'] for c in inspector.get_columns('chat')}
|
||||
if 'pinned' not in chat_columns:
|
||||
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
|
||||
if 'meta' not in chat_columns:
|
||||
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
|
||||
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
|
||||
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
|
||||
|
||||
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
|
||||
chat = table(
|
||||
|
||||
@@ -6,11 +6,11 @@ Create Date: 2024-10-09 21:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import select, table, update
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update
|
||||
|
||||
import json
|
||||
|
||||
revision = '242a2047eae0'
|
||||
down_revision = '6a39f3d8e55c'
|
||||
@@ -47,32 +47,29 @@ def upgrade():
|
||||
# If the column is already JSON, no need to do anything
|
||||
pass
|
||||
|
||||
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
|
||||
# Re-check columns after potential rename above
|
||||
current_cols = {c['name'] for c in inspector.get_columns('chat')}
|
||||
if 'old_chat' in current_cols:
|
||||
chat_table = table(
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('old_chat', sa.Text()),
|
||||
sa.Column('chat', sa.JSON()),
|
||||
)
|
||||
# Step 3: Migrate data from 'old_chat' to 'chat'
|
||||
chat_table = table(
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('old_chat', sa.Text()),
|
||||
sa.Column('chat', sa.JSON()),
|
||||
)
|
||||
|
||||
# - Selecting all data from the table
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
||||
for row in results:
|
||||
try:
|
||||
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
||||
json_data = json.loads(row.old_chat)
|
||||
except json.JSONDecodeError:
|
||||
json_data = None # Handle cases where the text cannot be converted to JSON
|
||||
# - Selecting all data from the table
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
||||
for row in results:
|
||||
try:
|
||||
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
||||
json_data = json.loads(row.old_chat)
|
||||
except json.JSONDecodeError:
|
||||
json_data = None # Handle cases where the text cannot be converted to JSON
|
||||
|
||||
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
|
||||
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
|
||||
|
||||
# Step 4: Drop 'old_chat' column
|
||||
print("Dropping 'old_chat' column")
|
||||
op.drop_column('chat', 'old_chat')
|
||||
# Step 4: Drop 'old_chat' column
|
||||
print("Dropping 'old_chat' column")
|
||||
op.drop_column('chat', 'old_chat')
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
+56
-69
@@ -8,9 +8,9 @@ Create Date: 2025-11-27 03:07:56.200231
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2f1211949ecc'
|
||||
@@ -20,76 +20,63 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# New columns to be added to channel_member table
|
||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
||||
if 'status' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
|
||||
if 'is_active' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
if 'is_channel_muted' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_muted',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'is_channel_pinned' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'data' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
|
||||
if 'meta' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
if 'joined_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
|
||||
if 'left_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
|
||||
if 'last_read_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
if 'updated_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_muted',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
|
||||
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
|
||||
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
# New columns to be added to message table
|
||||
msg_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
if 'is_pinned' not in msg_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column(
|
||||
'is_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'pinned_at' not in msg_cols:
|
||||
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
|
||||
if 'pinned_by' not in msg_cols:
|
||||
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column(
|
||||
'is_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,11 +6,11 @@ Create Date: 2026-01-23 17:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '374d2f66af06'
|
||||
down_revision: Union[str, None] = 'c440947495f3'
|
||||
@@ -20,167 +20,150 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# If the final state already exists (prompt has 'id' PK + prompt_history exists),
|
||||
# the migration completed successfully on a prior run — nothing to do.
|
||||
if 'prompt_history' in existing_tables and 'prompt_new' not in existing_tables:
|
||||
# prompt_history exists and prompt_new was already renamed → done
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'id' in prompt_cols and 'version_id' in prompt_cols:
|
||||
return
|
||||
# Step 1: Read existing data from OLD table (schema likely command as PK)
|
||||
# We use batch_alter previously, but we want to move to new table.
|
||||
# We need to assume the OLD structure.
|
||||
|
||||
# Step 1: Read existing data from OLD table (schema: command as PK)
|
||||
# Only read if the old-schema prompt table still exists (has 'command' but no 'version_id')
|
||||
existing_prompts = []
|
||||
if 'prompt' in existing_tables and 'prompt_new' not in existing_tables:
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'command' in prompt_cols and 'version_id' not in prompt_cols:
|
||||
old_prompt_table = sa.table(
|
||||
'prompt',
|
||||
sa.column('command', sa.Text()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('title', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('timestamp', sa.BigInteger()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
old_prompt_table = sa.table(
|
||||
'prompt',
|
||||
sa.column('command', sa.Text()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('title', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('timestamp', sa.BigInteger()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
)
|
||||
|
||||
# Check if table exists/read data
|
||||
try:
|
||||
existing_prompts = conn.execute(
|
||||
sa.select(
|
||||
old_prompt_table.c.command,
|
||||
old_prompt_table.c.user_id,
|
||||
old_prompt_table.c.title,
|
||||
old_prompt_table.c.content,
|
||||
old_prompt_table.c.timestamp,
|
||||
old_prompt_table.c.access_control,
|
||||
)
|
||||
try:
|
||||
existing_prompts = conn.execute(
|
||||
sa.select(
|
||||
old_prompt_table.c.command,
|
||||
old_prompt_table.c.user_id,
|
||||
old_prompt_table.c.title,
|
||||
old_prompt_table.c.content,
|
||||
old_prompt_table.c.timestamp,
|
||||
old_prompt_table.c.access_control,
|
||||
)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
existing_prompts = []
|
||||
).fetchall()
|
||||
except Exception:
|
||||
# Fallback if table doesn't exist (new install)
|
||||
existing_prompts = []
|
||||
|
||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY (if not already created)
|
||||
if 'prompt_new' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt_new',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('command', sa.String(), unique=True, index=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('version_id', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
|
||||
op.create_table(
|
||||
'prompt_new',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('command', sa.String(), unique=True, index=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('version_id', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 3: Create prompt_history table (if not already created)
|
||||
if 'prompt_history' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt_history',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('commit_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
# Step 3: Create prompt_history table
|
||||
op.create_table(
|
||||
'prompt_history',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('commit_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 4: Migrate data (only if we have old data to migrate)
|
||||
if existing_prompts:
|
||||
prompt_new_table = sa.table(
|
||||
'prompt_new',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('command', sa.String()),
|
||||
sa.column('user_id', sa.String()),
|
||||
sa.column('name', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('data', sa.JSON()),
|
||||
sa.column('meta', sa.JSON()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
sa.column('is_active', sa.Boolean()),
|
||||
sa.column('version_id', sa.Text()),
|
||||
sa.column('tags', sa.JSON()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
sa.column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
# Step 4: Migrate data
|
||||
prompt_new_table = sa.table(
|
||||
'prompt_new',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('command', sa.String()),
|
||||
sa.column('user_id', sa.String()),
|
||||
sa.column('name', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('data', sa.JSON()),
|
||||
sa.column('meta', sa.JSON()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
sa.column('is_active', sa.Boolean()),
|
||||
sa.column('version_id', sa.Text()),
|
||||
sa.column('tags', sa.JSON()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
sa.column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
prompt_history_table = sa.table(
|
||||
'prompt_history',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('prompt_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('snapshot', sa.JSON()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('commit_message', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
prompt_history_table = sa.table(
|
||||
'prompt_history',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('prompt_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('snapshot', sa.JSON()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('commit_message', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
for row in existing_prompts:
|
||||
command = row[0]
|
||||
user_id = row[1]
|
||||
title = row[2]
|
||||
content = row[3]
|
||||
timestamp = row[4]
|
||||
access_control = row[5]
|
||||
for row in existing_prompts:
|
||||
command = row[0]
|
||||
user_id = row[1]
|
||||
title = row[2]
|
||||
content = row[3]
|
||||
timestamp = row[4]
|
||||
access_control = row[5]
|
||||
|
||||
new_uuid = str(uuid.uuid4())
|
||||
history_uuid = str(uuid.uuid4())
|
||||
clean_command = command[1:] if command and command.startswith('/') else command
|
||||
new_uuid = str(uuid.uuid4())
|
||||
history_uuid = str(uuid.uuid4())
|
||||
clean_command = command[1:] if command and command.startswith('/') else command
|
||||
|
||||
# Insert into prompt_new
|
||||
conn.execute(
|
||||
sa.insert(prompt_new_table).values(
|
||||
id=new_uuid,
|
||||
command=clean_command,
|
||||
user_id=user_id,
|
||||
name=title,
|
||||
content=content,
|
||||
data={},
|
||||
meta={},
|
||||
access_control=access_control,
|
||||
is_active=True,
|
||||
version_id=history_uuid,
|
||||
tags=[],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
# Insert into prompt_new
|
||||
conn.execute(
|
||||
sa.insert(prompt_new_table).values(
|
||||
id=new_uuid,
|
||||
command=clean_command,
|
||||
user_id=user_id,
|
||||
name=title,
|
||||
content=content,
|
||||
data={},
|
||||
meta={},
|
||||
access_control=access_control,
|
||||
is_active=True,
|
||||
version_id=history_uuid,
|
||||
tags=[],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Create initial history entry
|
||||
conn.execute(
|
||||
sa.insert(prompt_history_table).values(
|
||||
id=history_uuid,
|
||||
prompt_id=new_uuid,
|
||||
parent_id=None,
|
||||
snapshot={
|
||||
'name': title,
|
||||
'content': content,
|
||||
'command': clean_command,
|
||||
'data': {},
|
||||
'meta': {},
|
||||
'access_control': access_control,
|
||||
},
|
||||
user_id=user_id,
|
||||
commit_message=None,
|
||||
created_at=timestamp,
|
||||
)
|
||||
# Create initial history entry
|
||||
conn.execute(
|
||||
sa.insert(prompt_history_table).values(
|
||||
id=history_uuid,
|
||||
prompt_id=new_uuid,
|
||||
parent_id=None,
|
||||
snapshot={
|
||||
'name': title,
|
||||
'content': content,
|
||||
'command': clean_command,
|
||||
'data': {},
|
||||
'meta': {},
|
||||
'access_control': access_control,
|
||||
},
|
||||
user_id=user_id,
|
||||
commit_message=None,
|
||||
created_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Step 5: Replace old table with new one (only if prompt_new exists)
|
||||
# Re-check tables after potential creation above
|
||||
inspector.clear_cache()
|
||||
current_tables = set(inspector.get_table_names())
|
||||
if 'prompt_new' in current_tables:
|
||||
if 'prompt' in current_tables:
|
||||
op.drop_table('prompt')
|
||||
op.rename_table('prompt_new', 'prompt')
|
||||
# Step 5: Replace old table with new one
|
||||
op.drop_table('prompt')
|
||||
op.rename_table('prompt_new', 'prompt')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2024-12-30 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '3781e22d8b01'
|
||||
down_revision = '7826ab40b532'
|
||||
@@ -16,50 +16,38 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Add 'type' column to the 'channel' table
|
||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
||||
if 'type' not in channel_cols:
|
||||
op.add_column(
|
||||
'channel',
|
||||
sa.Column(
|
||||
'type',
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'channel',
|
||||
sa.Column(
|
||||
'type',
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Add 'parent_id' column to the 'message' table for threads
|
||||
message_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
if 'parent_id' not in message_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
if 'message_reaction' not in existing_tables:
|
||||
op.create_table(
|
||||
'message_reaction',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
|
||||
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
|
||||
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
|
||||
)
|
||||
op.create_table(
|
||||
'message_reaction',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
|
||||
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
|
||||
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
|
||||
)
|
||||
|
||||
if 'channel_member' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'id', sa.Text(), nullable=False, primary_key=True, unique=True
|
||||
), # Record ID for the membership row
|
||||
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
|
||||
)
|
||||
op.create_table(
|
||||
'channel_member',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
|
||||
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -6,13 +6,13 @@ Create Date: 2025-11-17 03:45:25.123939
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import time
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '37f288994c47'
|
||||
@@ -22,13 +22,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'group_member' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
# 1. Create new table
|
||||
op.create_table(
|
||||
'group_member',
|
||||
|
||||
@@ -8,8 +8,8 @@ Create Date: 2025-09-08 14:19:59.583921
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '38d63c18f30f'
|
||||
@@ -19,39 +19,50 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
columns = inspector.get_columns('user')
|
||||
|
||||
# ── Create oauth_session table (idempotent) ───────────────────────
|
||||
if 'oauth_session' not in existing_tables:
|
||||
op.create_table(
|
||||
'oauth_session',
|
||||
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('user.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider', sa.Text(), nullable=False),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
pk_columns = inspector.get_pk_constraint('user')['constrained_columns']
|
||||
id_column = next((col for col in columns if col['name'] == 'id'), None)
|
||||
|
||||
# Create indexes (idempotent — no-ops when table was just created
|
||||
# with the columns above, and safe to call if indexes already exist).
|
||||
existing_indexes = (
|
||||
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
|
||||
if id_column and not id_column.get('unique', False):
|
||||
unique_constraints = inspector.get_unique_constraints('user')
|
||||
unique_columns = {tuple(u['column_names']) for u in unique_constraints}
|
||||
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
# If primary key is wrong, drop it
|
||||
if pk_columns and pk_columns != ['id']:
|
||||
batch_op.drop_constraint(inspector.get_pk_constraint('user')['name'], type_='primary')
|
||||
|
||||
# Add unique constraint if missing
|
||||
if ('id',) not in unique_columns:
|
||||
batch_op.create_unique_constraint('uq_user_id', ['id'])
|
||||
|
||||
# Re-create correct primary key
|
||||
batch_op.create_primary_key('pk_user_id', ['id'])
|
||||
|
||||
# Create oauth_session table
|
||||
op.create_table(
|
||||
'oauth_session',
|
||||
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('user.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider', sa.Text(), nullable=False),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
if 'idx_oauth_session_user_id' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
|
||||
if 'idx_oauth_session_expires_at' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
|
||||
if 'idx_oauth_session_user_provider' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
|
||||
# Create indexes for better performance
|
||||
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
|
||||
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
|
||||
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update, column
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.sql import column, select, table, update
|
||||
|
||||
import json
|
||||
|
||||
revision = '3ab32c4b8f59'
|
||||
down_revision = '1af9b942657b'
|
||||
|
||||
@@ -8,8 +8,8 @@ Create Date: 2025-08-21 02:07:18.078283
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3af16a1c9fb6'
|
||||
@@ -19,18 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
user_cols = {c['name'] for c in inspector.get_columns('user')}
|
||||
|
||||
if 'username' not in user_cols:
|
||||
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
|
||||
if 'bio' not in user_cols:
|
||||
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
|
||||
if 'gender' not in user_cols:
|
||||
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
|
||||
if 'date_of_birth' not in user_cols:
|
||||
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
|
||||
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
|
||||
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""add knowledge_directory table
|
||||
|
||||
Revision ID: 3c9b0ca343fd
|
||||
Revises: a0b1c2d3e4f5
|
||||
Create Date: 2026-05-13 21:58:40.832482
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3c9b0ca343fd'
|
||||
down_revision: Union[str, None] = 'a0b1c2d3e4f5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'knowledge_directory' not in existing_tables:
|
||||
# Create knowledge_directory table
|
||||
op.create_table(
|
||||
'knowledge_directory',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('knowledge_id', sa.Text(), nullable=False),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint(
|
||||
'knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id'])
|
||||
op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id'])
|
||||
|
||||
# Add directory_id column to knowledge_file
|
||||
kf_cols = {c['name'] for c in inspector.get_columns('knowledge_file')}
|
||||
if 'directory_id' not in kf_cols:
|
||||
with op.batch_alter_table('knowledge_file') as batch:
|
||||
batch.add_column(sa.Column('directory_id', sa.Text(), nullable=True))
|
||||
batch.create_foreign_key(
|
||||
'fk_knowledge_file_directory_id',
|
||||
'knowledge_directory',
|
||||
['directory_id'],
|
||||
['id'],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
batch.create_index('ix_knowledge_file_directory_id', ['directory_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove directory_id from knowledge_file
|
||||
with op.batch_alter_table('knowledge_file') as batch:
|
||||
batch.drop_index('ix_knowledge_file_directory_id')
|
||||
batch.drop_constraint('fk_knowledge_file_directory_id', type_='foreignkey')
|
||||
batch.drop_column('directory_id')
|
||||
|
||||
# Drop knowledge_directory table
|
||||
op.drop_index('ix_knowledge_directory_parent_id', table_name='knowledge_directory')
|
||||
op.drop_index('ix_knowledge_directory_knowledge_id', table_name='knowledge_directory')
|
||||
op.drop_table('knowledge_directory')
|
||||
@@ -6,15 +6,16 @@ Create Date: 2025-12-02 06:54:19.401334
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
import open_webui.internal.db
|
||||
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3e0e00844bb0'
|
||||
@@ -24,13 +25,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'knowledge_file' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
op.create_table(
|
||||
'knowledge_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
|
||||
-580
@@ -1,580 +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 dict, yielding (dot.path, value) for leaf nodes."""
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
|
||||
if 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}])
|
||||
-74
@@ -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
|
||||
+5
-21
@@ -6,8 +6,8 @@ Create Date: 2024-10-23 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '4ace53fd72c8'
|
||||
down_revision = 'af906e964978'
|
||||
@@ -16,18 +16,6 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {c['name']: c for c in inspector.get_columns('folder')}
|
||||
|
||||
created_at_col = columns.get('created_at')
|
||||
if not created_at_col:
|
||||
return
|
||||
|
||||
# Only convert if still DateTime — skip if already BigInteger
|
||||
if isinstance(created_at_col['type'], sa.BigInteger):
|
||||
return
|
||||
|
||||
# Perform safe alterations using batch operation
|
||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||
# Step 1: Remove server defaults for created_at and updated_at
|
||||
@@ -60,24 +48,20 @@ def upgrade():
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Convert columns back to DateTime and restore defaults. Mirrors the
|
||||
# upgrade's postgresql_using cast — without it, Postgres can't
|
||||
# auto-cast BigInteger → timestamp and aborts with DatatypeMismatch.
|
||||
# Downgrade: Convert columns back to DateTime and restore defaults
|
||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
'created_at',
|
||||
type_=sa.DateTime(),
|
||||
existing_type=sa.BigInteger(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
postgresql_using='to_timestamp(created_at)::timestamp without time zone',
|
||||
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||
)
|
||||
batch_op.alter_column(
|
||||
'updated_at',
|
||||
type_=sa.DateTime(),
|
||||
existing_type=sa.BigInteger(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
postgresql_using='to_timestamp(updated_at)::timestamp without time zone',
|
||||
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||
onupdate=sa.func.now(), # Restoring onupdate behavior if it was there
|
||||
)
|
||||
|
||||
@@ -8,9 +8,10 @@ Create Date: 2026-05-09 04:29:27.651341
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4de81c2a3af1'
|
||||
@@ -19,55 +20,46 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import insert, select, update
|
||||
from sqlalchemy.sql import column, table
|
||||
import time
|
||||
from sqlalchemy import select, update, insert
|
||||
from sqlalchemy.sql import table, column
|
||||
|
||||
|
||||
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()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'pinned_note' not in existing_tables:
|
||||
op.create_table(
|
||||
'pinned_note',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
|
||||
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table(
|
||||
'pinned_note',
|
||||
column('id', sa.Text),
|
||||
column('user_id', sa.Text),
|
||||
column('note_id', sa.Text),
|
||||
column('created_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
notes = conn.execute(select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)).fetchall()
|
||||
|
||||
if notes:
|
||||
now = int(time.time_ns())
|
||||
conn.execute(
|
||||
insert(pinned_note_table),
|
||||
[{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now} for note in notes],
|
||||
)
|
||||
|
||||
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table(
|
||||
'pinned_note',
|
||||
column('id', sa.Text),
|
||||
column('user_id', sa.Text),
|
||||
column('note_id', sa.Text),
|
||||
column('created_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
notes = conn.execute(
|
||||
select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)
|
||||
).fetchall()
|
||||
|
||||
if notes:
|
||||
now = int(time.time_ns())
|
||||
conn.execute(
|
||||
insert(pinned_note_table),
|
||||
[
|
||||
{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now}
|
||||
for note in notes
|
||||
],
|
||||
)
|
||||
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_pinned')
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_pinned')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -8,8 +8,9 @@ Create Date: 2026-04-19 16:20:58.162045
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '56359461a091'
|
||||
@@ -18,86 +19,58 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _index_exists(inspector, index_name, table_name):
|
||||
"""Check if an index already exists on the given table."""
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
op.create_table(
|
||||
'calendar',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
|
||||
|
||||
if 'calendar' not in tables:
|
||||
op.create_table(
|
||||
'calendar',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table(
|
||||
'calendar_event',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('calendar_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('start_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('end_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('all_day', sa.Boolean(), nullable=False),
|
||||
sa.Column('rrule', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('location', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
|
||||
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_user', 'calendar'):
|
||||
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
|
||||
|
||||
if 'calendar_event' not in tables:
|
||||
op.create_table(
|
||||
'calendar_event',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('calendar_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('start_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('end_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('all_day', sa.Boolean(), nullable=False),
|
||||
sa.Column('rrule', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('location', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar_event' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_event_calendar', 'calendar_event'):
|
||||
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
|
||||
if not _index_exists(inspector, 'ix_calendar_event_user_date', 'calendar_event'):
|
||||
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
|
||||
|
||||
if 'calendar_event_attendee' not in tables:
|
||||
op.create_table(
|
||||
'calendar_event_attendee',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('event_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar_event_attendee' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_event_attendee_user', 'calendar_event_attendee'):
|
||||
op.create_index(
|
||||
'ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
'calendar_event_attendee',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('event_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
|
||||
)
|
||||
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2024-12-22 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '57c599a3cb57'
|
||||
down_revision = '922e7a387820'
|
||||
@@ -16,36 +16,30 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
op.create_table(
|
||||
'channel',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('name', sa.Text()),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if 'channel' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('name', sa.Text()),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if 'message' not in existing_tables:
|
||||
op.create_table(
|
||||
'message',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('channel_id', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text()),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
'message',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('channel_id', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text()),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -8,9 +8,9 @@ Create Date: 2025-12-10 15:11:39.424601
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6283dc0e4d8d'
|
||||
@@ -20,38 +20,31 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'channel_file' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_channel_file_channel_id', 'channel_id'),
|
||||
sa.Index('ix_channel_file_file_id', 'file_id'),
|
||||
sa.Index('ix_channel_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint(
|
||||
'channel_id', 'file_id', name='uq_channel_file_channel_file'
|
||||
), # prevent duplicate entries
|
||||
)
|
||||
op.create_table(
|
||||
'channel_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_channel_file_channel_id', 'channel_id'),
|
||||
sa.Index('ix_channel_file_file_id', 'file_id'),
|
||||
sa.Index('ix_channel_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,11 +6,10 @@ Create Date: 2024-10-01 14:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import column, select, table
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, column, select
|
||||
import json
|
||||
|
||||
revision = '6a39f3d8e55c'
|
||||
down_revision = 'c0fbf31ca0db'
|
||||
@@ -19,67 +18,62 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
# Creating the 'knowledge' table
|
||||
print('Creating knowledge table')
|
||||
knowledge_table = op.create_table(
|
||||
'knowledge',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if 'knowledge' not in existing_tables:
|
||||
# Creating the 'knowledge' table
|
||||
print('Creating knowledge table')
|
||||
knowledge_table = op.create_table(
|
||||
'knowledge',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
print('Migrating data from document table to knowledge table')
|
||||
# Representation of the existing 'document' table
|
||||
document_table = table(
|
||||
'document',
|
||||
column('collection_name', sa.String()),
|
||||
column('user_id', sa.String()),
|
||||
column('name', sa.String()),
|
||||
column('title', sa.Text()),
|
||||
column('content', sa.Text()),
|
||||
column('timestamp', sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Select all from existing document table
|
||||
documents = op.get_bind().execute(
|
||||
select(
|
||||
document_table.c.collection_name,
|
||||
document_table.c.user_id,
|
||||
document_table.c.name,
|
||||
document_table.c.title,
|
||||
document_table.c.content,
|
||||
document_table.c.timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
print('Migrating data from document table to knowledge table')
|
||||
# Representation of the existing 'document' table
|
||||
document_table = table(
|
||||
'document',
|
||||
column('collection_name', sa.String()),
|
||||
column('user_id', sa.String()),
|
||||
column('name', sa.String()),
|
||||
column('title', sa.Text()),
|
||||
column('content', sa.Text()),
|
||||
column('timestamp', sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Select all from existing document table
|
||||
documents = conn.execute(
|
||||
select(
|
||||
document_table.c.collection_name,
|
||||
document_table.c.user_id,
|
||||
document_table.c.name,
|
||||
document_table.c.title,
|
||||
document_table.c.content,
|
||||
document_table.c.timestamp,
|
||||
# Insert data into knowledge table from document table
|
||||
for doc in documents:
|
||||
op.get_bind().execute(
|
||||
knowledge_table.insert().values(
|
||||
id=doc.collection_name,
|
||||
user_id=doc.user_id,
|
||||
description=doc.name,
|
||||
meta={
|
||||
'legacy': True,
|
||||
'document': True,
|
||||
'tags': json.loads(doc.content or '{}').get('tags', []),
|
||||
},
|
||||
name=doc.title,
|
||||
created_at=doc.timestamp,
|
||||
updated_at=doc.timestamp, # using created_at for both created_at and updated_at in project
|
||||
)
|
||||
)
|
||||
|
||||
# Insert data into knowledge table from document table
|
||||
for doc in documents:
|
||||
conn.execute(
|
||||
knowledge_table.insert().values(
|
||||
id=doc.collection_name,
|
||||
user_id=doc.user_id,
|
||||
description=doc.name,
|
||||
meta={
|
||||
'legacy': True,
|
||||
'document': True,
|
||||
'tags': json.loads(doc.content or '{}').get('tags', []),
|
||||
},
|
||||
name=doc.title,
|
||||
created_at=doc.timestamp,
|
||||
updated_at=doc.timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('knowledge')
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2024-12-23 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '7826ab40b532'
|
||||
down_revision = '57c599a3cb57'
|
||||
@@ -16,15 +16,10 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
if 'access_control' not in file_cols:
|
||||
op.add_column(
|
||||
'file',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'file',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -1,34 +1,44 @@
|
||||
# Initial bootstrap migration version.
|
||||
# Revision ID: 7e5b5dc7342b
|
||||
# Revises: (none)
|
||||
# Created on: 2024-06-24 13:15:33.808998
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
import open_webui.internal.db # noqa: F401
|
||||
"""init
|
||||
|
||||
Revision ID: 7e5b5dc7342b
|
||||
Revises:
|
||||
Create Date: 2024-06-24 13:15:33.808998
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import open_webui.internal.db
|
||||
from open_webui.internal.db import JSONField
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7e5b5dc7342b'
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
# Initial schema table declarations
|
||||
_INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
(
|
||||
'auth',
|
||||
[
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
if 'auth' not in existing_tables:
|
||||
op.create_table(
|
||||
'auth',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('password', sa.Text(), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'chat',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'chat' not in existing_tables:
|
||||
op.create_table(
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
@@ -37,23 +47,24 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('share_id', sa.Text(), nullable=True),
|
||||
sa.Column('archived', sa.Boolean(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('share_id')],
|
||||
),
|
||||
(
|
||||
'chatidtag',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('share_id'),
|
||||
)
|
||||
|
||||
if 'chatidtag' not in existing_tables:
|
||||
op.create_table(
|
||||
'chatidtag',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('tag_name', sa.String(), nullable=True),
|
||||
sa.Column('chat_id', sa.String(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'document',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'document' not in existing_tables:
|
||||
op.create_table(
|
||||
'document',
|
||||
sa.Column('collection_name', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
@@ -61,23 +72,24 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('collection_name'), sa.UniqueConstraint('name')],
|
||||
),
|
||||
(
|
||||
'file',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('collection_name'),
|
||||
sa.UniqueConstraint('name'),
|
||||
)
|
||||
|
||||
if 'file' not in existing_tables:
|
||||
op.create_table(
|
||||
'file',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('filename', sa.Text(), nullable=True),
|
||||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'function',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'function' not in existing_tables:
|
||||
op.create_table(
|
||||
'function',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
@@ -89,23 +101,23 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
sa.Column('is_global', sa.Boolean(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'memory',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'memory' not in existing_tables:
|
||||
op.create_table(
|
||||
'memory',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'model',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'model' not in existing_tables:
|
||||
op.create_table(
|
||||
'model',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('base_model_id', sa.Text(), nullable=True),
|
||||
@@ -114,33 +126,33 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'prompt',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'prompt' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt',
|
||||
sa.Column('command', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('command')],
|
||||
),
|
||||
(
|
||||
'tag',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('command'),
|
||||
)
|
||||
|
||||
if 'tag' not in existing_tables:
|
||||
op.create_table(
|
||||
'tag',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('data', sa.Text(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'tool',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'tool' not in existing_tables:
|
||||
op.create_table(
|
||||
'tool',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
@@ -150,12 +162,12 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
sa.Column('valves', JSONField(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'user',
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'user' not in existing_tables:
|
||||
op.create_table(
|
||||
'user',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
@@ -168,25 +180,25 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
sa.Column('settings', JSONField(), nullable=True),
|
||||
sa.Column('info', JSONField(), nullable=True),
|
||||
sa.Column('oauth_sub', sa.Text(), nullable=True),
|
||||
],
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('api_key'),
|
||||
sa.UniqueConstraint('oauth_sub'),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
# --- migration execution ---
|
||||
def upgrade() -> None: # deploy initial schema tables
|
||||
existing_tables = set(get_existing_tables())
|
||||
for name, columns, constraints in _INITIAL_TABLES:
|
||||
if name not in existing_tables:
|
||||
op.create_table(name, *columns, *constraints)
|
||||
|
||||
|
||||
# --- rollback function ---
|
||||
def downgrade() -> None: # rollback initial schema tables
|
||||
for table_name, _, _ in reversed(_INITIAL_TABLES):
|
||||
op.drop_table(table_name)
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('user')
|
||||
op.drop_table('tool')
|
||||
op.drop_table('tag')
|
||||
op.drop_table('prompt')
|
||||
op.drop_table('model')
|
||||
op.drop_table('memory')
|
||||
op.drop_table('function')
|
||||
op.drop_table('file')
|
||||
op.drop_table('document')
|
||||
op.drop_table('chatidtag')
|
||||
op.drop_table('chat')
|
||||
op.drop_table('auth')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
+12
-19
@@ -8,9 +8,9 @@ Create Date: 2025-12-10 16:07:58.001282
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '81cc2ce44d79'
|
||||
@@ -20,27 +20,20 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Add message_id column to channel_file table
|
||||
cf_cols = {c['name'] for c in inspector.get_columns('channel_file')}
|
||||
if 'message_id' not in cf_cols:
|
||||
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'message_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
|
||||
nullable=True,
|
||||
)
|
||||
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'message_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Add data column to knowledge table
|
||||
k_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
||||
if 'data' not in k_cols:
|
||||
with op.batch_alter_table('knowledge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
|
||||
with op.batch_alter_table('knowledge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,13 +6,13 @@ Create Date: 2026-02-01 04:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,13 +56,6 @@ def _flush_batch(conn, table, batch):
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'chat_message' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
# Step 1: Create table
|
||||
op.create_table(
|
||||
'chat_message',
|
||||
@@ -92,6 +85,8 @@ def upgrade() -> None:
|
||||
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
|
||||
|
||||
# Step 2: Backfill from existing chats
|
||||
conn = op.get_bind()
|
||||
|
||||
chat_table = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.Text()),
|
||||
|
||||
+35
-44
@@ -8,9 +8,9 @@ Create Date: 2025-11-30 06:33:38.790341
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '90ef40d4714e'
|
||||
@@ -20,53 +20,42 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Update 'channel' table
|
||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
||||
if 'is_private' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
|
||||
if 'archived_at' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
|
||||
if 'archived_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
|
||||
if 'deleted_at' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
|
||||
if 'deleted_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
|
||||
if 'updated_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
|
||||
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
|
||||
|
||||
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
|
||||
|
||||
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
|
||||
|
||||
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
|
||||
|
||||
# Update 'channel_member' table
|
||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
||||
if 'role' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
|
||||
if 'invited_by' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
|
||||
if 'invited_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
|
||||
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
# Create 'channel_webhook' table
|
||||
if 'channel_webhook' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_webhook',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
'channel_webhook',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
@@ -85,3 +74,5 @@ def downgrade() -> None:
|
||||
|
||||
# Drop 'channel_webhook' table
|
||||
op.drop_table('channel_webhook')
|
||||
|
||||
pass
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2024-11-14 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '922e7a387820'
|
||||
down_revision = '4ace53fd72c8'
|
||||
@@ -16,55 +16,70 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'group' not in existing_tables:
|
||||
op.create_table(
|
||||
'group',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('permissions', sa.JSON(), nullable=True),
|
||||
sa.Column('user_ids', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
'group',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('permissions', sa.JSON(), nullable=True),
|
||||
sa.Column('user_ids', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'model' table
|
||||
model_cols = {c['name'] for c in inspector.get_columns('model')}
|
||||
if 'access_control' not in model_cols:
|
||||
op.add_column('model', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
if 'is_active' not in model_cols:
|
||||
op.add_column(
|
||||
'model',
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
||||
)
|
||||
op.add_column(
|
||||
'model',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'is_active' column to 'model' table
|
||||
op.add_column(
|
||||
'model',
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'knowledge' table
|
||||
knowledge_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
||||
if 'access_control' not in knowledge_cols:
|
||||
op.add_column('knowledge', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
'knowledge',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'prompt' table
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'access_control' not in prompt_cols:
|
||||
op.add_column('prompt', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
'prompt',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'tools' table
|
||||
tool_cols = {c['name'] for c in inspector.get_columns('tool')}
|
||||
if 'access_control' not in tool_cols:
|
||||
op.add_column('tool', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
'tool',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('group')
|
||||
|
||||
# Drop 'access_control' column from 'model' table
|
||||
op.drop_column('model', 'access_control')
|
||||
|
||||
# Drop 'is_active' column from 'model' table
|
||||
op.drop_column('model', 'is_active')
|
||||
|
||||
# Drop 'access_control' column from 'knowledge' table
|
||||
op.drop_column('knowledge', 'access_control')
|
||||
|
||||
# Drop 'access_control' column from 'prompt' table
|
||||
op.drop_column('prompt', 'access_control')
|
||||
|
||||
# Drop 'access_control' column from 'tools' table
|
||||
op.drop_column('tool', 'access_control')
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-05-03 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '9f0c9cd09105'
|
||||
down_revision = '3781e22d8b01'
|
||||
@@ -16,22 +16,17 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'note' not in existing_tables:
|
||||
op.create_table(
|
||||
'note',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
'note',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -6,7 +6,6 @@ Create Date: 2025-09-15 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'a0b1c2d3e4f5'
|
||||
@@ -16,12 +15,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_indexes = {idx['name'] for idx in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_user_id' not in existing_indexes:
|
||||
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
|
||||
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -8,8 +8,9 @@ Create Date: 2026-02-11 09:30:00.000000
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
|
||||
+3
-9
@@ -8,8 +8,8 @@ Create Date: 2026-03-29 22:15:00.000000
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3dd5bedd151'
|
||||
@@ -19,14 +19,8 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'tasks' not in columns:
|
||||
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
|
||||
if 'summary' not in columns:
|
||||
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
|
||||
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
|
||||
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
+8
-10
@@ -8,8 +8,8 @@ Create Date: 2025-09-27 02:24:18.058455
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a5c220713937'
|
||||
@@ -19,18 +19,16 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
msg_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
|
||||
# Add 'reply_to_id' column to the 'message' table for replying to messages
|
||||
if 'reply_to_id' not in msg_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('reply_to_id', sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('reply_to_id', sa.Text(), nullable=True),
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove 'reply_to_id' column from the 'message' table
|
||||
op.drop_column('message', 'reply_to_id')
|
||||
|
||||
pass
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2024-10-20 17:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# Revision identifiers, used by Alembic.
|
||||
revision = 'af906e964978'
|
||||
@@ -17,28 +17,23 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'feedback' not in existing_tables:
|
||||
# ### Create feedback table ###
|
||||
op.create_table(
|
||||
'feedback',
|
||||
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
|
||||
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
|
||||
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
|
||||
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
||||
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
|
||||
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
|
||||
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
|
||||
sa.Column(
|
||||
'created_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback creation timestamp (BIGINT representing epoch)
|
||||
sa.Column(
|
||||
'updated_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback update timestamp (BIGINT representing epoch)
|
||||
)
|
||||
# ### Create feedback table ###
|
||||
op.create_table(
|
||||
'feedback',
|
||||
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
|
||||
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
|
||||
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
|
||||
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
||||
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
|
||||
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
|
||||
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
|
||||
sa.Column(
|
||||
'created_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback creation timestamp (BIGINT representing epoch)
|
||||
sa.Column(
|
||||
'updated_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback update timestamp (BIGINT representing epoch)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -6,13 +6,15 @@ Create Date: 2025-11-28 04:55:31.737538
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
import open_webui.internal.db
|
||||
import json
|
||||
import time
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b10670c03dd5'
|
||||
@@ -20,49 +22,20 @@ down_revision: Union[str, None] = '2f1211949ecc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# ── Ad-hoc table references for Core DML ─────────────────────────────────
|
||||
# These are lightweight table() / column() references used only inside this
|
||||
# migration for SELECT / UPDATE / INSERT — they do NOT create or alter
|
||||
# anything on disk.
|
||||
|
||||
_user = sa.table(
|
||||
'user',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('oauth_sub', sa.Text),
|
||||
sa.column('oauth', sa.JSON),
|
||||
sa.column('api_key', sa.Text),
|
||||
sa.column('info', sa.Text),
|
||||
sa.column('settings', sa.Text),
|
||||
)
|
||||
|
||||
_api_key = sa.table(
|
||||
'api_key',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('user_id', sa.Text),
|
||||
sa.column('key', sa.Text),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
|
||||
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
|
||||
"""
|
||||
SQLite requires manual removal of any user-created indexes referencing
|
||||
a column before ALTER TABLE ... DROP COLUMN can succeed.
|
||||
|
||||
NOTE: PRAGMAs have no Core equivalent — raw text is unavoidable here.
|
||||
SQLite requires manual removal of any indexes referencing a column
|
||||
before ALTER TABLE ... DROP COLUMN can succeed.
|
||||
"""
|
||||
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
|
||||
|
||||
for idx in indexes:
|
||||
index_name = idx[1] # index name
|
||||
# Skip system-managed autoindexes (PK / UNIQUE constraints) — they
|
||||
# cannot be dropped directly and will disappear when the column is
|
||||
# removed via batch_alter_table.
|
||||
if index_name.startswith('sqlite_autoindex_'):
|
||||
continue
|
||||
# Get indexed columns
|
||||
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
|
||||
indexed_cols = [row[2] for row in idx_info]
|
||||
|
||||
indexed_cols = [row[2] for row in idx_info] # col names
|
||||
if column_name in indexed_cols:
|
||||
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
|
||||
|
||||
@@ -71,31 +44,33 @@ def _convert_column_to_json(table: str, column: str):
|
||||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
|
||||
t_json = sa.column(f'{column}_json', sa.JSON)
|
||||
|
||||
# SQLite cannot ALTER COLUMN → must recreate column
|
||||
if dialect == 'sqlite':
|
||||
# 1. Add temporary column
|
||||
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
|
||||
|
||||
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
|
||||
# 2. Load old data
|
||||
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||
|
||||
for uid, raw in rows:
|
||||
for row in rows:
|
||||
uid, raw = row
|
||||
if raw is None:
|
||||
parsed = None
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None
|
||||
parsed = None # fallback safe behavior
|
||||
|
||||
conn.execute(
|
||||
sa.update(sa.table(table, sa.column('id'), t_json))
|
||||
.where(sa.column('id') == uid)
|
||||
.values({f'{column}_json': json.dumps(parsed) if parsed else None})
|
||||
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
|
||||
{'val': json.dumps(parsed) if parsed else None, 'id': uid},
|
||||
)
|
||||
|
||||
# 3. Drop old TEXT column
|
||||
op.drop_column(table, column)
|
||||
|
||||
# 4. Rename new JSON column → original name
|
||||
op.alter_column(table, f'{column}_json', new_column_name=column)
|
||||
|
||||
else:
|
||||
@@ -112,19 +87,15 @@ def _convert_column_to_text(table: str, column: str):
|
||||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
t = sa.table(table, sa.column('id', sa.Text), sa.column(column))
|
||||
t_text = sa.column(f'{column}_text', sa.Text)
|
||||
|
||||
if dialect == 'sqlite':
|
||||
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
|
||||
|
||||
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
|
||||
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||
|
||||
for uid, raw in rows:
|
||||
conn.execute(
|
||||
sa.update(sa.table(table, sa.column('id'), t_text))
|
||||
.where(sa.column('id') == uid)
|
||||
.values({f'{column}_text': json.dumps(raw) if raw else None})
|
||||
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
|
||||
{'val': json.dumps(raw) if raw else None, 'id': uid},
|
||||
)
|
||||
|
||||
op.drop_column(table, column)
|
||||
@@ -140,93 +111,88 @@ def _convert_column_to_text(table: str, column: str):
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('user', sa.Column('profile_banner_image_url', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('timezone', sa.String(), nullable=True))
|
||||
|
||||
op.add_column('user', sa.Column('presence_state', sa.String(), nullable=True))
|
||||
op.add_column('user', sa.Column('status_emoji', sa.String(), nullable=True))
|
||||
op.add_column('user', sa.Column('status_message', sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('status_expires_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
op.add_column('user', sa.Column('oauth', sa.JSON(), nullable=True))
|
||||
|
||||
# Convert info (TEXT/JSONField) → JSON
|
||||
_convert_column_to_json('user', 'info')
|
||||
# Convert settings (TEXT/JSONField) → JSON
|
||||
_convert_column_to_json('user', 'settings')
|
||||
|
||||
op.create_table(
|
||||
'api_key',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
|
||||
sa.Column('key', sa.Text(), unique=True, nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
user_columns = {c['name'] for c in inspector.get_columns('user')}
|
||||
users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall()
|
||||
|
||||
# ── Add new columns (idempotent) ──────────────────────────────────
|
||||
for col_name, col_type in [
|
||||
('profile_banner_image_url', sa.Text()),
|
||||
('timezone', sa.String()),
|
||||
('presence_state', sa.String()),
|
||||
('status_emoji', sa.String()),
|
||||
('status_message', sa.Text()),
|
||||
('status_expires_at', sa.BigInteger()),
|
||||
('oauth', sa.JSON()),
|
||||
]:
|
||||
if col_name not in user_columns:
|
||||
op.add_column('user', sa.Column(col_name, col_type, nullable=True))
|
||||
for uid, oauth_sub in users:
|
||||
if oauth_sub:
|
||||
# Example formats supported:
|
||||
# provider@sub
|
||||
# plain sub (stored as {"oidc": {"sub": sub}})
|
||||
if '@' in oauth_sub:
|
||||
provider, sub = oauth_sub.split('@', 1)
|
||||
else:
|
||||
provider, sub = 'oidc', oauth_sub
|
||||
|
||||
# Convert info (TEXT/JSONField) → JSON (skip if already JSON)
|
||||
user_col_types = {c['name']: c['type'] for c in inspector.get_columns('user')}
|
||||
if isinstance(user_col_types.get('info'), sa.Text):
|
||||
_convert_column_to_json('user', 'info')
|
||||
# Convert settings (TEXT/JSONField) → JSON (skip if already JSON)
|
||||
if isinstance(user_col_types.get('settings'), sa.Text):
|
||||
_convert_column_to_json('user', 'settings')
|
||||
oauth_json = json.dumps({provider: {'sub': sub}})
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
|
||||
{'oauth': oauth_json, 'id': uid},
|
||||
)
|
||||
|
||||
# ── Create api_key table (idempotent) ─────────────────────────────
|
||||
if 'api_key' not in existing_tables:
|
||||
op.create_table(
|
||||
'api_key',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
|
||||
sa.Column('key', sa.Text(), unique=True, nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall()
|
||||
now = int(time.time())
|
||||
|
||||
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
|
||||
if 'oauth_sub' in user_columns:
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
|
||||
for uid, api_key in users_with_keys:
|
||||
if api_key:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
|
||||
VALUES (:id, :user_id, :key, :created_at, :updated_at)
|
||||
"""),
|
||||
{
|
||||
'id': f'key_{uid}',
|
||||
'user_id': uid,
|
||||
'key': api_key,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
)
|
||||
|
||||
for uid, oauth_sub in rows:
|
||||
if oauth_sub:
|
||||
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
|
||||
conn.execute(
|
||||
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
|
||||
)
|
||||
if conn.dialect.name == 'sqlite':
|
||||
_drop_sqlite_indexes_for_column('user', 'api_key', conn)
|
||||
_drop_sqlite_indexes_for_column('user', 'oauth_sub', conn)
|
||||
|
||||
# ── Migrate api_key column → api_key table (only if old column still exists)
|
||||
if 'api_key' in user_columns:
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
|
||||
now = int(time.time())
|
||||
|
||||
for uid, key_val in rows:
|
||||
if key_val:
|
||||
conn.execute(
|
||||
sa.insert(_api_key).values(
|
||||
id=f'key_{uid}',
|
||||
user_id=uid,
|
||||
key=key_val,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Drop legacy columns (idempotent) ──────────────────────────────
|
||||
cols_to_drop = {'api_key', 'oauth_sub'} & user_columns
|
||||
if cols_to_drop:
|
||||
if conn.dialect.name == 'sqlite':
|
||||
for col in cols_to_drop:
|
||||
_drop_sqlite_indexes_for_column('user', col, conn)
|
||||
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
for col in cols_to_drop:
|
||||
batch_op.drop_column(col)
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
batch_op.drop_column('api_key')
|
||||
batch_op.drop_column('oauth_sub')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# --- 1. Restore old oauth_sub column ---
|
||||
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
|
||||
users = conn.execute(sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')).fetchall()
|
||||
|
||||
for uid, oauth in rows:
|
||||
for uid, oauth in users:
|
||||
try:
|
||||
data = json.loads(oauth)
|
||||
provider = list(data.keys())[0]
|
||||
@@ -235,17 +201,25 @@ def downgrade() -> None:
|
||||
except Exception:
|
||||
oauth_sub = None
|
||||
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth_sub=oauth_sub))
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
|
||||
{'oauth_sub': oauth_sub, 'id': uid},
|
||||
)
|
||||
|
||||
op.drop_column('user', 'oauth')
|
||||
|
||||
# --- Restore api_key field ---
|
||||
# --- 2. Restore api_key field ---
|
||||
op.add_column('user', sa.Column('api_key', sa.String(), nullable=True))
|
||||
|
||||
keys = conn.execute(sa.select(_api_key.c.user_id, _api_key.c.key)).fetchall()
|
||||
# Restore values from api_key
|
||||
keys = conn.execute(sa.text('SELECT user_id, key FROM api_key')).fetchall()
|
||||
for uid, key in keys:
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(api_key=key))
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
|
||||
{'key': key, 'id': uid},
|
||||
)
|
||||
|
||||
# Drop new table
|
||||
op.drop_table('api_key')
|
||||
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
|
||||
+2
-7
@@ -8,8 +8,8 @@ Create Date: 2026-02-13 14:19:00.000000
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2c3d4e5f6a7'
|
||||
@@ -19,12 +19,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
user_cols = {c['name'] for c in inspector.get_columns('user')}
|
||||
|
||||
if 'scim' not in user_cols:
|
||||
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
|
||||
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,8 +6,9 @@ Create Date: 2026-04-01 04:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b7c8d9e0f1a2'
|
||||
@@ -17,14 +18,9 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'last_read_at' not in columns:
|
||||
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
# Set existing chats to be marked as read
|
||||
op.execute('UPDATE chat SET last_read_at = updated_at')
|
||||
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
# Set existing chats to be marked as read
|
||||
op.execute('UPDATE chat SET last_read_at = updated_at')
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -19,17 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
if 'hash' not in file_cols:
|
||||
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
|
||||
if 'data' not in file_cols:
|
||||
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
|
||||
if 'updated_at' not in file_cols:
|
||||
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
|
||||
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
|
||||
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -9,8 +9,8 @@ Create Date: 2026-04-16 23:00:00.000000
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'e1f2a3b4c5d6'
|
||||
@@ -61,21 +61,18 @@ access_grant_t = sa.table(
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# 1. Create shared_chat table (idempotent)
|
||||
if 'shared_chat' not in tables:
|
||||
op.create_table(
|
||||
'shared_chat',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('chat', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
# 1. Create shared_chat table
|
||||
op.create_table(
|
||||
'shared_chat',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('chat', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# 2. Migrate existing shared-* rows
|
||||
shared_rows = conn.execute(
|
||||
@@ -99,51 +96,31 @@ def upgrade():
|
||||
if not original:
|
||||
continue
|
||||
|
||||
# Check if shared_chat record already exists (idempotent)
|
||||
existing_shared = conn.execute(
|
||||
sa.select(shared_chat_t.c.id).where(shared_chat_t.c.id == share_token)
|
||||
).fetchone()
|
||||
|
||||
if not existing_shared:
|
||||
# Insert snapshot into shared_chat
|
||||
conn.execute(
|
||||
shared_chat_t.insert().values(
|
||||
id=share_token,
|
||||
chat_id=original_chat_id,
|
||||
user_id=original.user_id,
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
# Insert snapshot into shared_chat
|
||||
conn.execute(
|
||||
shared_chat_t.insert().values(
|
||||
id=share_token,
|
||||
chat_id=original_chat_id,
|
||||
user_id=original.user_id,
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# Check if access_grant record already exists (idempotent)
|
||||
existing_grant = conn.execute(
|
||||
sa.select(access_grant_t.c.id).where(
|
||||
sa.and_(
|
||||
access_grant_t.c.resource_type == 'shared_chat',
|
||||
access_grant_t.c.resource_id == original_chat_id,
|
||||
access_grant_t.c.principal_type == 'user',
|
||||
access_grant_t.c.principal_id == '*',
|
||||
access_grant_t.c.permission == 'read',
|
||||
)
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if not existing_grant:
|
||||
# Create user:*:read grant for backward compat
|
||||
conn.execute(
|
||||
access_grant_t.insert().values(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type='shared_chat',
|
||||
resource_id=original_chat_id,
|
||||
principal_type='user',
|
||||
principal_id='*',
|
||||
permission='read',
|
||||
created_at=row.created_at or int(time.time()),
|
||||
)
|
||||
# Create user:*:read grant for backward compat
|
||||
conn.execute(
|
||||
access_grant_t.insert().values(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type='shared_chat',
|
||||
resource_id=original_chat_id,
|
||||
principal_type='user',
|
||||
principal_id='*',
|
||||
permission='read',
|
||||
created_at=row.created_at or int(time.time()),
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Clean up old phantom rows
|
||||
conn.execute(
|
||||
|
||||
@@ -6,12 +6,11 @@ Create Date: 2024-10-20 17:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import JSON, String, Text, and_
|
||||
from sqlalchemy.sql import column, table
|
||||
import sqlalchemy as sa
|
||||
import json
|
||||
from sqlalchemy.sql import table, column
|
||||
from sqlalchemy import String, Text, JSON, and_
|
||||
|
||||
revision = 'c29facfe716b'
|
||||
down_revision = 'c69f45358db4'
|
||||
@@ -20,13 +19,8 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
# 1. Add the `path` column to the "file" table.
|
||||
if 'path' not in file_cols:
|
||||
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
|
||||
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
|
||||
|
||||
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
|
||||
# Use Alembic's default batch_op for dialect compatibility.
|
||||
|
||||
@@ -8,8 +8,8 @@ Create Date: 2025-12-21 20:27:41.694897
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c440947495f3'
|
||||
@@ -19,39 +19,36 @@ 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 'chat_file' not in existing_tables:
|
||||
op.create_table(
|
||||
'chat_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'chat_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('chat.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('message_id', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_chat_file_chat_id', 'chat_id'),
|
||||
sa.Index('ix_chat_file_file_id', 'file_id'),
|
||||
sa.Index('ix_chat_file_message_id', 'message_id'),
|
||||
sa.Index('ix_chat_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
|
||||
)
|
||||
op.create_table(
|
||||
'chat_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'chat_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('chat.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('message_id', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_chat_file_chat_id', 'chat_id'),
|
||||
sa.Index('ix_chat_file_file_id', 'file_id'),
|
||||
sa.Index('ix_chat_file_message_id', 'message_id'),
|
||||
sa.Index('ix_chat_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('chat_file')
|
||||
pass
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2024-10-16 02:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'c69f45358db4'
|
||||
down_revision = '3ab32c4b8f59'
|
||||
@@ -16,37 +16,30 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
op.create_table(
|
||||
'folder',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('items', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id', 'user_id'),
|
||||
)
|
||||
|
||||
if 'folder' not in existing_tables:
|
||||
op.create_table(
|
||||
'folder',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('items', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id', 'user_id'),
|
||||
)
|
||||
|
||||
chat_cols = {c['name'] for c in inspector.get_columns('chat')}
|
||||
if 'folder_id' not in chat_cols:
|
||||
op.add_column(
|
||||
'chat',
|
||||
sa.Column('folder_id', sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'chat',
|
||||
sa.Column('folder_id', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Add config table.
|
||||
"""Add config table
|
||||
|
||||
Revision ID: ca81bd47c050
|
||||
Revises: 7e5b5dc7342b
|
||||
Create Date: 2024-08-25 15:26:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
@@ -10,40 +11,29 @@ from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ca81bd47c050'
|
||||
down_revision: Union[str, None] = '7e5b5dc7342b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create a key-value config table with versioning."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'config' not in existing_tables:
|
||||
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),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
)
|
||||
def upgrade():
|
||||
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),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the config table."""
|
||||
def downgrade():
|
||||
op.drop_table('config')
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-07-13 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'd31026856c01'
|
||||
down_revision = '9f0c9cd09105'
|
||||
@@ -16,12 +16,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
folder_cols = {c['name'] for c in inspector.get_columns('folder')}
|
||||
|
||||
if 'data' not in folder_cols:
|
||||
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
|
||||
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -7,8 +7,8 @@ Create Date: 2026-03-30
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'd4e5f6a7b8c9'
|
||||
down_revision: Union[str, None] = 'a3dd5bedd151'
|
||||
@@ -16,56 +16,36 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _index_exists(inspector, index_name, table_name):
|
||||
"""Check if an index already exists on the given table (works for both SQLite and PostgreSQL)."""
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
op.create_table(
|
||||
'automation',
|
||||
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('data', sa.JSON(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
|
||||
|
||||
if 'automation' not in tables:
|
||||
op.create_table(
|
||||
'automation',
|
||||
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('data', sa.JSON(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'automation' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_automation_next_run', 'automation'):
|
||||
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
|
||||
|
||||
if 'automation_run' not in tables:
|
||||
op.create_table(
|
||||
'automation_run',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('automation_id', sa.Text(), nullable=False),
|
||||
sa.Column('chat_id', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'automation_run' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_automation_run_automation_id', 'automation_run'):
|
||||
op.create_index(
|
||||
'ix_automation_run_automation_id',
|
||||
'automation_run',
|
||||
['automation_id'],
|
||||
)
|
||||
op.create_table(
|
||||
'automation_run',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('automation_id', sa.Text(), nullable=False),
|
||||
sa.Column('chat_id', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_automation_run_automation_id',
|
||||
'automation_run',
|
||||
['automation_id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -6,8 +6,8 @@ Create Date: 2026-04-14 22:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'e1f2a3b4c5d6'
|
||||
down_revision = 'b7c8d9e0f1a2'
|
||||
@@ -16,12 +16,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('note')]
|
||||
|
||||
if 'is_pinned' not in columns:
|
||||
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
|
||||
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -11,12 +11,13 @@ Access control semantics:
|
||||
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
import time
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = 'f1e2d3c4b5a6'
|
||||
@@ -80,17 +81,12 @@ def upgrade() -> None:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
|
||||
# Check if access_control and id columns exist (may already be dropped on re-run,
|
||||
# or table may have been rebuilt without id during intermediate migration states)
|
||||
insp = sa.inspect(conn)
|
||||
insp.clear_cache() # Ensure fresh metadata after prior migrations that rebuild tables
|
||||
table_cols = {c['name'] for c in insp.get_columns(table_name)}
|
||||
if 'access_control' not in table_cols or 'id' not in table_cols:
|
||||
continue
|
||||
|
||||
# Query all rows
|
||||
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
|
||||
rows = result.fetchall()
|
||||
try:
|
||||
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
resource_id = row[0]
|
||||
@@ -212,15 +208,15 @@ def upgrade() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drop access_control columns from resource tables (only if column still exists)
|
||||
inspector = sa.inspect(conn)
|
||||
# Drop access_control columns from resource tables
|
||||
for table_name, _ in resource_tables:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
cols = {c['name'] for c in inspector.get_columns(table_name)}
|
||||
if 'access_control' in cols:
|
||||
try:
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.drop_column('access_control')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -3,11 +3,13 @@ import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, and_, delete, or_, select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -621,8 +623,8 @@ class AccessGrantsTable:
|
||||
Get all users who have the specified permission on a resource.
|
||||
Returns a list of UserModel instances.
|
||||
"""
|
||||
from open_webui.models.users import Users, UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import UserModel, Users
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
|
||||
+118
-145
@@ -1,56 +1,50 @@
|
||||
"""Auth credential models and data-access layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import select, delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import Boolean, Column, String, Text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Pre-computed hash verified on signin paths that lack a real credential
|
||||
# (unknown user, inactive account) so response timing cannot reveal
|
||||
# whether an account exists (CWE-208).
|
||||
PLACEHOLDER_HASH = bcrypt.hashpw(b'placeholder', bcrypt.gensalt()).decode('utf-8')
|
||||
####################
|
||||
# DB MODEL
|
||||
####################
|
||||
|
||||
|
||||
class Auth(Base): # credential ↔ user linkage
|
||||
"""Maps a user ID to an email/password pair with an active flag."""
|
||||
|
||||
class Auth(Base):
|
||||
__tablename__ = 'auth'
|
||||
|
||||
id = Column(String, primary_key=True, unique=True) # mirrors User.id
|
||||
email = Column(String) # login address, kept in sync with User.email
|
||||
password = Column(Text) # argon2 / bcrypt hash
|
||||
active = Column(Boolean) # account soft-disable toggle
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
email = Column(String)
|
||||
password = Column(Text)
|
||||
active = Column(Boolean)
|
||||
|
||||
|
||||
class AuthModel(BaseModel):
|
||||
"""Pydantic mirror of the ``auth`` table row."""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
password: str
|
||||
active: bool = True
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""JWT bearer-token response wrapper."""
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class ApiKey(BaseModel):
|
||||
api_key: str | None = None
|
||||
api_key: Optional[str] = None
|
||||
|
||||
|
||||
class SigninResponse(Token, UserProfileImageResponse):
|
||||
@@ -80,26 +74,21 @@ class SignupForm(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
password: str
|
||||
profile_image_url: str | None = '/user.png'
|
||||
profile_image_url: Optional[str] = '/user.png'
|
||||
|
||||
@field_validator('profile_image_url')
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: str | None) -> str | None:
|
||||
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None:
|
||||
return validate_profile_image_url(v)
|
||||
return v
|
||||
|
||||
|
||||
class AddUserForm(SignupForm):
|
||||
role: str | None = 'pending'
|
||||
|
||||
|
||||
# --- data-access layer ---
|
||||
role: Optional[str] = 'pending'
|
||||
|
||||
|
||||
class AuthsTable:
|
||||
"""Provides CRUD operations for the Auth ↔ User lifecycle."""
|
||||
|
||||
async def insert_new_auth(
|
||||
self,
|
||||
email: str,
|
||||
@@ -107,133 +96,117 @@ class AuthsTable:
|
||||
name: str,
|
||||
profile_image_url: str = '/user.png',
|
||||
role: str = 'pending',
|
||||
oauth: dict | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Create an Auth + User pair inside a single transaction."""
|
||||
async with get_async_db_context(db) as session:
|
||||
oauth: Optional[dict] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[UserModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
log.info('insert_new_auth')
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
id = str(uuid.uuid4())
|
||||
|
||||
credential = Auth(
|
||||
id=new_id,
|
||||
email=email,
|
||||
password=password,
|
||||
active=True,
|
||||
)
|
||||
session.add(credential)
|
||||
auth = AuthModel(**{'id': id, 'email': email, 'password': password, 'active': True})
|
||||
result = Auth(**auth.model_dump())
|
||||
db.add(result)
|
||||
|
||||
created_user = await Users.insert_new_user(
|
||||
new_id,
|
||||
name,
|
||||
email,
|
||||
profile_image_url,
|
||||
role,
|
||||
oauth=oauth,
|
||||
db=session,
|
||||
)
|
||||
# persist both records and reload generated defaults
|
||||
await session.commit()
|
||||
await session.refresh(credential)
|
||||
return created_user if credential and created_user else None
|
||||
user = await Users.insert_new_user(id, name, email, profile_image_url, role, oauth=oauth, db=db)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
|
||||
if result and user:
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
|
||||
async def authenticate_user(
|
||||
self,
|
||||
email: str,
|
||||
verify_password: callable,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Verify email + password credentials and return the matching user."""
|
||||
log.info('authenticate_user: %s', email)
|
||||
resolved = await Users.get_user_by_email(email, db=db)
|
||||
if not resolved:
|
||||
verify_password(PLACEHOLDER_HASH)
|
||||
return
|
||||
# load the credential row and verify the password hash
|
||||
async with get_async_db_context(db) as session:
|
||||
credential = await session.get(Auth, resolved.id)
|
||||
if not credential or not credential.active:
|
||||
verify_password(PLACEHOLDER_HASH)
|
||||
return
|
||||
if not verify_password(credential.password):
|
||||
return
|
||||
return resolved
|
||||
self, email: str, verify_password: callable, db: Optional[AsyncSession] = None
|
||||
) -> Optional[UserModel]:
|
||||
log.info(f'authenticate_user: {email}')
|
||||
|
||||
user = await Users.get_user_by_email(email, db=db)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Auth).filter_by(id=user.id, active=True))
|
||||
auth = result.scalars().first()
|
||||
if auth:
|
||||
if verify_password(auth.password):
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def authenticate_user_by_api_key(
|
||||
self,
|
||||
api_key: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Look up the user that owns the given API key."""
|
||||
log.info('authenticate_user_by_api_key')
|
||||
self, api_key: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[UserModel]:
|
||||
log.info(f'authenticate_user_by_api_key')
|
||||
# if no api_key, return None
|
||||
if not api_key:
|
||||
return
|
||||
# delegate to the Users model for the actual lookup
|
||||
return await Users.get_user_by_api_key(api_key, db=db)
|
||||
return None
|
||||
|
||||
async def authenticate_user_by_email(
|
||||
self,
|
||||
email: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Single-query auth via JOIN on Auth ↔ User, filtered by active flag."""
|
||||
log.info('authenticate_user_by_email: %s', email)
|
||||
# single JOIN avoids N+1 — returns (Auth, User) tuple or None
|
||||
async with get_async_db_context(db) as session:
|
||||
joined_query = (
|
||||
select(Auth, User).join(User, Auth.id == User.id).where(Auth.email == email, Auth.active.is_(True))
|
||||
)
|
||||
match = (await session.execute(joined_query)).first()
|
||||
if not match:
|
||||
return
|
||||
_, found_user = match
|
||||
return UserModel.model_validate(found_user)
|
||||
try:
|
||||
user = await Users.get_user_by_api_key(api_key, db=db)
|
||||
return user if user else None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def update_email_by_id(
|
||||
self,
|
||||
user_id: str,
|
||||
email: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Set a new email on the auth record and propagate to the user row."""
|
||||
async with get_async_db_context(db) as session:
|
||||
auth_row = await session.get(Auth, user_id)
|
||||
if auth_row is None:
|
||||
async def authenticate_user_by_email(self, email: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]:
|
||||
log.info(f'authenticate_user_by_email: {email}')
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Single JOIN query instead of two separate queries
|
||||
result = await db.execute(
|
||||
select(Auth, User).join(User, Auth.id == User.id).filter(Auth.email == email, Auth.active == True)
|
||||
)
|
||||
row = result.first()
|
||||
if row:
|
||||
_, user = row
|
||||
return UserModel.model_validate(user)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def update_user_password_by_id(self, id: str, new_password: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(update(Auth).filter_by(id=id).values(password=new_password))
|
||||
await db.commit()
|
||||
return True if result.rowcount == 1 else False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def update_email_by_id(self, id: str, email: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(update(Auth).filter_by(id=id).values(email=email))
|
||||
await db.commit()
|
||||
if result.rowcount == 1:
|
||||
await Users.update_user_by_id(id, {'email': email}, db=db)
|
||||
return True
|
||||
return False
|
||||
auth_row.email = email
|
||||
await session.commit()
|
||||
await Users.update_user_by_id(user_id, {'email': email}, db=session)
|
||||
return True
|
||||
# --- password modification ---
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def update_user_password_by_id(
|
||||
self,
|
||||
user_id: str,
|
||||
new_password: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Set a new password hash for an existing user."""
|
||||
async with get_async_db_context(db) as session:
|
||||
auth_row = await session.get(Auth, user_id)
|
||||
if auth_row is None:
|
||||
return False
|
||||
auth_row.password = new_password
|
||||
await session.commit()
|
||||
return True
|
||||
async def delete_auth_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Delete User
|
||||
result = await Users.delete_user_by_id(id, db=db)
|
||||
|
||||
async def delete_auth_by_id(
|
||||
self,
|
||||
id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Remove a user and their auth credential in one transaction."""
|
||||
async with get_async_db_context(db) as session:
|
||||
if not await Users.delete_user_by_id(id, db=session):
|
||||
return False
|
||||
await session.execute(delete(Auth).where(Auth.id == id))
|
||||
await session.commit()
|
||||
return True
|
||||
if result:
|
||||
await db.execute(delete(Auth).filter_by(id=id))
|
||||
await db.commit()
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
Auths = AuthsTable() # singleton — module-level instance
|
||||
Auths = AuthsTable()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update
|
||||
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String, delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import logging
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Text,
|
||||
JSON,
|
||||
Boolean,
|
||||
BigInteger,
|
||||
Index,
|
||||
UniqueConstraint,
|
||||
select,
|
||||
or_,
|
||||
exists,
|
||||
func,
|
||||
delete,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, UserResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
Index,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
delete,
|
||||
exists,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -695,19 +696,12 @@ class CalendarEventTable:
|
||||
self,
|
||||
now_ns: int,
|
||||
default_lookahead_ns: int,
|
||||
grace_ns: int = 0,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[tuple[CalendarEventModel, Optional[str]]]:
|
||||
"""Events starting between now and now + lookahead, for alert processing.
|
||||
|
||||
Per-event lookahead is read from meta.alert_minutes (falls back to
|
||||
default_lookahead_ns). Returns (event, user_timezone) pairs.
|
||||
|
||||
*grace_ns* widens the SQL lower bound so that events whose start_at
|
||||
is up to *grace_ns* nanoseconds in the past are still fetched. This
|
||||
ensures "At time of event" alerts (alert_minutes=0) are not missed
|
||||
when the scheduler polls a few seconds after the event's exact start
|
||||
time.
|
||||
"""
|
||||
from open_webui.models.users import User as UserRow
|
||||
|
||||
@@ -722,7 +716,7 @@ class CalendarEventTable:
|
||||
.outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
|
||||
.filter(
|
||||
CalendarEvent.is_cancelled == False,
|
||||
CalendarEvent.start_at >= now_ns - grace_ns,
|
||||
CalendarEvent.start_at >= now_ns,
|
||||
CalendarEvent.start_at <= upper,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,33 +4,31 @@ import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
|
||||
from sqlalchemy import select, delete, update, func, case, or_, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import (
|
||||
AccessGrantModel,
|
||||
AccessGrants,
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
String,
|
||||
Text,
|
||||
JSON,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
case,
|
||||
delete,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
####################
|
||||
# Channel DB Schema
|
||||
|
||||
@@ -3,26 +3,21 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select, delete, func, cast, Integer, distinct
|
||||
from sqlalchemy import select, delete, func, cast, Integer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.utils.response import normalize_usage
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Text,
|
||||
cast,
|
||||
delete,
|
||||
func,
|
||||
select,
|
||||
JSON,
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
####################
|
||||
# Helpers
|
||||
@@ -174,7 +169,7 @@ class ChatMessageTable:
|
||||
# Update existing
|
||||
if 'role' in data:
|
||||
existing.role = data['role']
|
||||
if 'parent_id' in data or 'parentId' in data:
|
||||
if 'parent_id' in data:
|
||||
existing.parent_id = data.get('parent_id') or data.get('parentId')
|
||||
if 'content' in data:
|
||||
existing.content = data.get('content')
|
||||
@@ -396,24 +391,6 @@ class ChatMessageTable:
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
async def delete_message_ids_by_chat_id(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_ids: set[str],
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> bool:
|
||||
"""Delete specific ``chat_message`` rows by their original message IDs."""
|
||||
if not message_ids:
|
||||
return True
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(
|
||||
delete(ChatMessage)
|
||||
.where(ChatMessage.chat_id == chat_id)
|
||||
.where(ChatMessage.id.in_({f'{chat_id}-{mid}' for mid in message_ids}))
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
# Analytics methods
|
||||
async def get_message_count_by_model(
|
||||
self,
|
||||
@@ -442,44 +419,6 @@ class ChatMessageTable:
|
||||
result = await db.execute(stmt)
|
||||
return {row.model_id: row.count for row in result.all()}
|
||||
|
||||
async def get_unique_counts_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Count distinct users and chats per model."""
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
stmt = select(
|
||||
ChatMessage.model_id,
|
||||
func.count(distinct(ChatMessage.user_id)).label('unique_users'),
|
||||
func.count(distinct(ChatMessage.chat_id)).label('unique_chats'),
|
||||
).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.model_id)
|
||||
result = await db.execute(stmt)
|
||||
return {
|
||||
row.model_id: {
|
||||
'unique_users': row.unique_users,
|
||||
'unique_chats': row.unique_chats,
|
||||
}
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
async def get_token_usage_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
@@ -640,7 +579,6 @@ class ChatMessageTable:
|
||||
"""Get message counts grouped by day and model."""
|
||||
async with get_async_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
|
||||
+313
-452
File diff suppressed because it is too large
Load Diff
@@ -1,177 +0,0 @@
|
||||
"""Database-backed configuration with per-key storage.
|
||||
|
||||
Replaces the old single-row JSON blob machinery with a simple per-key model
|
||||
mirroring cptr's Config.
|
||||
|
||||
Each config key is stored as its own row: key TEXT PK, value JSON.
|
||||
Reads are direct DB lookups. Writes are explicit awaited upserts that raise on
|
||||
failure (no more fire-and-forget create_task).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db
|
||||
from sqlalchemy import JSON, BigInteger, Column, Text, select
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Model ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Config(Base):
|
||||
"""Per-key config storage. Each row is one config key."""
|
||||
|
||||
__tablename__ = 'config'
|
||||
|
||||
key = Column(Text, primary_key=True)
|
||||
value = Column(JSON, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=True)
|
||||
|
||||
DEFAULTS: ClassVar[dict[str, Any]] = {}
|
||||
PERSISTENT_ENABLED: ClassVar[bool] = True
|
||||
OAUTH_PERSISTENT_ENABLED: ClassVar[bool] = False
|
||||
|
||||
# ── Class methods ────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def configure(
|
||||
cls,
|
||||
*,
|
||||
defaults: dict[str, Any] | None = None,
|
||||
enable_persistent: bool = True,
|
||||
enable_oauth_persistent: bool = False,
|
||||
) -> None:
|
||||
cls.DEFAULTS = defaults or {}
|
||||
cls.PERSISTENT_ENABLED = enable_persistent
|
||||
cls.OAUTH_PERSISTENT_ENABLED = enable_oauth_persistent
|
||||
|
||||
@classmethod
|
||||
def default_value(cls, key: str, default: Any = None) -> Any:
|
||||
return cls.DEFAULTS.get(key, default)
|
||||
|
||||
@classmethod
|
||||
def persistent_enabled_for(cls, key: str) -> bool:
|
||||
if not cls.PERSISTENT_ENABLED:
|
||||
return False
|
||||
if key.startswith('oauth.') and not cls.OAUTH_PERSISTENT_ENABLED:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get(key: str, default: Any = None) -> Any:
|
||||
"""Get a config value by key. Returns default if not set."""
|
||||
if not Config.persistent_enabled_for(key):
|
||||
return Config.default_value(key, default)
|
||||
async with get_async_db() as db:
|
||||
row = await db.get(Config, key)
|
||||
return row.value if row else Config.default_value(key, default)
|
||||
|
||||
@staticmethod
|
||||
async def get_many(*keys: str) -> dict:
|
||||
"""Get multiple config values. Returns {key: value} for keys that exist."""
|
||||
disabled_values = {
|
||||
key: Config.default_value(key)
|
||||
for key in keys
|
||||
if not Config.persistent_enabled_for(key) and key in Config.DEFAULTS
|
||||
}
|
||||
enabled_keys = {key for key in keys if Config.persistent_enabled_for(key)}
|
||||
if not enabled_keys:
|
||||
return disabled_values
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config).where(Config.key.in_(enabled_keys)))
|
||||
values = {row.key: row.value for row in result.scalars().all()}
|
||||
return {
|
||||
key: values.get(key, Config.default_value(key))
|
||||
for key in keys
|
||||
if key in values or key in Config.DEFAULTS or key in disabled_values
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_namespace(namespace: str) -> dict:
|
||||
"""Get all config keys under a dotted namespace."""
|
||||
default_values = {
|
||||
key: value
|
||||
for key, value in Config.DEFAULTS.items()
|
||||
if key.startswith(f'{namespace}.') and not Config.persistent_enabled_for(key)
|
||||
}
|
||||
if not Config.PERSISTENT_ENABLED:
|
||||
return default_values
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config).where(Config.key.like(f'{namespace}.%')))
|
||||
values = {row.key: row.value for row in result.scalars().all()}
|
||||
values.update(default_values)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
"""Get all config as {key: value}."""
|
||||
if not Config.PERSISTENT_ENABLED:
|
||||
return dict(Config.DEFAULTS)
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config))
|
||||
values = {row.key: row.value for row in result.scalars().all()}
|
||||
if not Config.OAUTH_PERSISTENT_ENABLED:
|
||||
values.update({key: value for key, value in Config.DEFAULTS.items() if key.startswith('oauth.')})
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
async def upsert(updates: dict) -> None:
|
||||
"""Upsert multiple config key-value pairs. Raises on failure."""
|
||||
async with get_async_db() as db:
|
||||
now = int(time.time())
|
||||
for key, value in updates.items():
|
||||
existing = await db.get(Config, key)
|
||||
if existing:
|
||||
existing.value = value
|
||||
existing.updated_at = now
|
||||
else:
|
||||
db.add(Config(key=key, value=value, updated_at=now))
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> bool:
|
||||
"""Delete a config key. Returns True if it existed."""
|
||||
async with get_async_db() as db:
|
||||
row = await db.get(Config, key)
|
||||
if row:
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def clear() -> None:
|
||||
"""Delete all config rows."""
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
async with get_async_db() as db:
|
||||
await db.execute(sa_delete(Config))
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def seed_defaults(defaults: dict) -> None:
|
||||
"""Insert keys that don't yet exist in the DB.
|
||||
|
||||
Called at startup to ensure all known config keys have values.
|
||||
Existing DB values take precedence over defaults.
|
||||
"""
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config.key))
|
||||
existing_keys = {row[0] for row in result.all()}
|
||||
|
||||
now = int(time.time())
|
||||
new_count = 0
|
||||
for key, value in defaults.items():
|
||||
if key not in existing_keys:
|
||||
db.add(Config(key=key, value=value, updated_at=now))
|
||||
existing_keys.add(key)
|
||||
new_count += 1
|
||||
|
||||
if new_count:
|
||||
await db.commit()
|
||||
log.info('Seeded %d new config defaults', new_count)
|
||||
@@ -3,11 +3,13 @@ import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.users import User, UserModel
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -133,12 +135,6 @@ class ModelHistoryEntry(BaseModel):
|
||||
lost: int
|
||||
|
||||
|
||||
class ModelHistoryCounts(BaseModel):
|
||||
date: str
|
||||
won: int = 0
|
||||
lost: int = 0
|
||||
|
||||
|
||||
class ModelHistoryResponse(BaseModel):
|
||||
model_id: str
|
||||
history: list[ModelHistoryEntry]
|
||||
@@ -222,19 +218,12 @@ class FeedbackTable:
|
||||
) -> FeedbackListResponse:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Feedback, User).join(User, Feedback.user_id == User.id)
|
||||
count_stmt = (
|
||||
select(func.count(Feedback.id))
|
||||
.select_from(Feedback)
|
||||
.join(User, Feedback.user_id == User.id)
|
||||
)
|
||||
|
||||
if filter:
|
||||
# Apply model_id filter (exact match)
|
||||
model_id = filter.get('model_id')
|
||||
if model_id:
|
||||
model_id_filter = Feedback.data['model_id'].as_string() == model_id
|
||||
stmt = stmt.filter(model_id_filter)
|
||||
count_stmt = count_stmt.filter(model_id_filter)
|
||||
stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id)
|
||||
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
@@ -263,9 +252,9 @@ class FeedbackTable:
|
||||
else:
|
||||
stmt = stmt.order_by(Feedback.created_at.desc())
|
||||
|
||||
# Count before pagination without wrapping the ordered item query.
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar() or 0
|
||||
# Count BEFORE pagination
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
@@ -330,8 +319,8 @@ class FeedbackTable:
|
||||
If days=0, returns all time data starting from first feedback.
|
||||
Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
if days == 0:
|
||||
@@ -388,83 +377,15 @@ class FeedbackTable:
|
||||
|
||||
return result
|
||||
|
||||
async def get_model_feedback_counts_by_day(
|
||||
self,
|
||||
model_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[ModelHistoryCounts]:
|
||||
"""Get aggregated feedback counts per day for a model, preserving all matching days."""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Feedback.created_at, Feedback.data).filter(Feedback.data['model_id'].as_string() == model_id)
|
||||
if start_date is not None:
|
||||
stmt = stmt.filter(Feedback.created_at >= start_date)
|
||||
|
||||
result = await db.execute(stmt.order_by(Feedback.created_at.asc()))
|
||||
rows = result.all()
|
||||
|
||||
daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0})
|
||||
|
||||
for created_at, data in rows:
|
||||
if not data:
|
||||
continue
|
||||
|
||||
rating_str = str(data.get('rating', ''))
|
||||
if rating_str not in ('1', '-1'):
|
||||
continue
|
||||
|
||||
date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d')
|
||||
if rating_str == '1':
|
||||
daily_counts[date_str]['won'] += 1
|
||||
else:
|
||||
daily_counts[date_str]['lost'] += 1
|
||||
|
||||
return [
|
||||
ModelHistoryCounts(date=date_str, won=counts['won'], lost=counts['lost'])
|
||||
for date_str, counts in sorted(daily_counts.items())
|
||||
]
|
||||
|
||||
async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()))
|
||||
return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
|
||||
|
||||
async def get_feedbacks_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> FeedbackListResponse:
|
||||
async def get_feedbacks_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = (
|
||||
select(Feedback, User)
|
||||
.join(User, Feedback.user_id == User.id)
|
||||
.filter(Feedback.user_id == user_id)
|
||||
.order_by(Feedback.updated_at.desc())
|
||||
)
|
||||
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = result.all()
|
||||
|
||||
feedbacks = []
|
||||
for feedback, user in items:
|
||||
feedback_model = FeedbackModel.model_validate(feedback)
|
||||
user_model = UserResponse.model_validate(user)
|
||||
feedbacks.append(FeedbackUserResponse(**feedback_model.model_dump(), user=user_model))
|
||||
|
||||
return FeedbackListResponse(items=feedbacks, total=total)
|
||||
result = await db.execute(select(Feedback).filter_by(user_id=user_id).order_by(Feedback.updated_at.desc()))
|
||||
return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
|
||||
|
||||
async def update_feedback_by_id(
|
||||
self, id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
"""File upload models, forms, and database operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
# local imports
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.utils.misc import sanitize_metadata
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
# Files DB Schema
|
||||
# What is written here bears witness. Let the testimony
|
||||
# remain as it was given, and let none tamper with it.
|
||||
####################
|
||||
|
||||
class File(Base): # uploaded file record
|
||||
|
||||
class File(Base):
|
||||
__tablename__ = 'file'
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
user_id = Column(String, index=True) # owner user id
|
||||
user_id = Column(String)
|
||||
hash = Column(Text, nullable=True)
|
||||
|
||||
filename = Column(Text) # original upload filename
|
||||
filename = Column(Text)
|
||||
path = Column(Text, nullable=True)
|
||||
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger, index=True) # upload timestamp
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
|
||||
@@ -36,23 +39,27 @@ class FileModel(BaseModel):
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
hash: str | None = None
|
||||
hash: Optional[str] = None
|
||||
|
||||
filename: str
|
||||
path: str | None = None
|
||||
path: Optional[str] = None
|
||||
|
||||
data: dict | None = None
|
||||
meta: dict | None = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
created_at: int | None # timestamp in epoch
|
||||
updated_at: int | None # timestamp in epoch
|
||||
created_at: Optional[int] # timestamp in epoch
|
||||
updated_at: Optional[int] # timestamp in epoch
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
# --- metadata structures ---
|
||||
class FileMeta(BaseModel):
|
||||
name: str | None = None
|
||||
content_type: str | None = None
|
||||
size: int | None = None
|
||||
name: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
@@ -77,22 +84,22 @@ class FileMeta(BaseModel):
|
||||
class FileModelResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
hash: str | None = None
|
||||
hash: Optional[str] = None
|
||||
|
||||
filename: str
|
||||
data: dict | None = None
|
||||
meta: FileMeta | None = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[FileMeta] = None
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int | None = None # timestamp in epoch, optional for legacy files
|
||||
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
class FileMetadataResponse(BaseModel):
|
||||
id: str
|
||||
hash: str | None = None
|
||||
meta: dict | None = None
|
||||
hash: Optional[str] = None
|
||||
meta: Optional[dict] = None
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
@@ -104,7 +111,7 @@ class FileListResponse(BaseModel):
|
||||
|
||||
class FileForm(BaseModel):
|
||||
id: str
|
||||
hash: str | None = None
|
||||
hash: Optional[str] = None
|
||||
filename: str
|
||||
path: str
|
||||
data: dict = {}
|
||||
@@ -112,15 +119,15 @@ class FileForm(BaseModel):
|
||||
|
||||
|
||||
class FileUpdateForm(BaseModel):
|
||||
hash: str | None = None
|
||||
data: dict | None = None
|
||||
meta: dict | None = None
|
||||
hash: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
|
||||
class FilesTable:
|
||||
async def insert_new_file(
|
||||
self, user_id: str, form_data: FileForm, db: AsyncSession | None = None
|
||||
) -> FileModel | None:
|
||||
self, user_id: str, form_data: FileForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
file_data = form_data.model_dump()
|
||||
|
||||
@@ -149,26 +156,22 @@ class FilesTable:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f'Error inserting a new file: {e}')
|
||||
return None # insertion failed
|
||||
return None
|
||||
|
||||
async def get_file_by_id(
|
||||
self,
|
||||
id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> FileModel | None:
|
||||
"""Look up a file by its primary key."""
|
||||
async def get_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FileModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
file = await db.get(File, id)
|
||||
if not file:
|
||||
try:
|
||||
file = await db.get(File, id)
|
||||
return FileModel.model_validate(file) if file else None
|
||||
except Exception:
|
||||
return None
|
||||
return FileModel.model_validate(file)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_file_by_id_and_user_id(
|
||||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> FileModel | None:
|
||||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
result = await db.execute(select(File).filter_by(id=id, user_id=user_id))
|
||||
@@ -180,7 +183,9 @@ class FilesTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_file_metadata_by_id(self, id: str, db: AsyncSession | None = None) -> FileMetadataResponse | None:
|
||||
async def get_file_metadata_by_id(
|
||||
self, id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FileMetadataResponse]:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
file = await db.get(File, id)
|
||||
@@ -196,24 +201,12 @@ class FilesTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_files(self, db: AsyncSession | None = None) -> list[FileModel]:
|
||||
async def get_files(self, db: Optional[AsyncSession] = None) -> list[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(File))
|
||||
return [FileModel.model_validate(file) for file in result.scalars().all()]
|
||||
|
||||
async def count_files_by_user_id(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(func.count(File.id))
|
||||
if user_id:
|
||||
stmt = stmt.filter_by(user_id=user_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def check_access_by_user_id(self, id, user_id, permission='write', db: AsyncSession | None = None) -> bool:
|
||||
async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool:
|
||||
file = await self.get_file_by_id(id, db=db)
|
||||
if not file:
|
||||
return False
|
||||
@@ -222,13 +215,13 @@ class FilesTable:
|
||||
# Implement additional access control logic here as needed
|
||||
return False
|
||||
|
||||
async def get_files_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> list[FileModel]:
|
||||
async def get_files_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> list[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(File).filter(File.id.in_(ids)).order_by(File.updated_at.desc()))
|
||||
return [FileModel.model_validate(file) for file in result.scalars().all()]
|
||||
|
||||
async def get_file_metadatas_by_ids(
|
||||
self, ids: list[str], db: AsyncSession | None = None
|
||||
self, ids: list[str], db: Optional[AsyncSession] = None
|
||||
) -> list[FileMetadataResponse]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
@@ -247,17 +240,17 @@ class FilesTable:
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
async def get_files_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[FileModel]:
|
||||
async def get_files_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(File).filter_by(user_id=user_id))
|
||||
return [FileModel.model_validate(file) for file in result.scalars().all()]
|
||||
|
||||
async def get_file_list(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
user_id: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: AsyncSession | None = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> 'FileListResponse':
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(File)
|
||||
@@ -297,11 +290,11 @@ class FilesTable:
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
user_id: Optional[str] = None,
|
||||
filename: str = '*',
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession | None = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[FileModel]:
|
||||
"""
|
||||
Search files with glob pattern matching, optional user filter, and pagination.
|
||||
@@ -330,8 +323,8 @@ class FilesTable:
|
||||
return [FileModel.model_validate(file) for file in result.scalars().all()]
|
||||
|
||||
async def update_file_by_id(
|
||||
self, id: str, form_data: FileUpdateForm, db: AsyncSession | None = None
|
||||
) -> FileModel | None:
|
||||
self, id: str, form_data: FileUpdateForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
result = await db.execute(select(File).filter_by(id=id))
|
||||
@@ -354,8 +347,8 @@ class FilesTable:
|
||||
return None
|
||||
|
||||
async def update_file_hash_by_id(
|
||||
self, id: str, hash: str | None, db: AsyncSession | None = None
|
||||
) -> FileModel | None:
|
||||
self, id: str, hash: Optional[str], db: Optional[AsyncSession] = None
|
||||
) -> Optional[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
result = await db.execute(select(File).filter_by(id=id))
|
||||
@@ -368,7 +361,9 @@ class FilesTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def update_file_data_by_id(self, id: str, data: dict, db: AsyncSession | None = None) -> FileModel | None:
|
||||
async def update_file_data_by_id(
|
||||
self, id: str, data: dict, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
result = await db.execute(select(File).filter_by(id=id))
|
||||
@@ -380,7 +375,9 @@ class FilesTable:
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
async def update_file_metadata_by_id(self, id: str, meta: dict, db: AsyncSession | None = None) -> FileModel | None:
|
||||
async def update_file_metadata_by_id(
|
||||
self, id: str, meta: dict, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FileModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
result = await db.execute(select(File).filter_by(id=id))
|
||||
@@ -392,58 +389,7 @@ class FilesTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def update_file_name_by_id(self, id: str, name: str, db: AsyncSession | None = None) -> FileModel | None:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
result = await db.execute(select(File).filter_by(id=id))
|
||||
file = result.scalars().first()
|
||||
file.filename = name
|
||||
file.meta = {**(file.meta if file.meta else {}), 'name': name}
|
||||
file.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
return FileModel.model_validate(file)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_pending_files_for_knowledge(
|
||||
self, knowledge_id: str, db: AsyncSession | None = None
|
||||
) -> list[FileModelResponse]:
|
||||
"""Return files still being processed for this knowledge base.
|
||||
|
||||
These are files uploaded with ``meta.data.knowledge_id`` set, whose
|
||||
``data.status`` is still ``pending`` or ``processing``, and which
|
||||
have not yet been added to the ``knowledge_file`` join table.
|
||||
|
||||
The JSON subscript syntax (``Column['key']['subkey'].as_string()``)
|
||||
is supported by both SQLite (``json_extract``) and PostgreSQL
|
||||
(``->>``/``->``).
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from open_webui.models.knowledge import KnowledgeFile
|
||||
|
||||
# Subquery: file IDs already linked to this knowledge base
|
||||
linked_ids = (
|
||||
select(KnowledgeFile.file_id).filter(KnowledgeFile.knowledge_id == knowledge_id).correlate(None)
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(File)
|
||||
.filter(
|
||||
File.meta['data']['knowledge_id'].as_string() == knowledge_id,
|
||||
File.data['status'].as_string().in_(['pending', 'processing']),
|
||||
File.id.notin_(linked_ids),
|
||||
)
|
||||
.order_by(File.created_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [FileModelResponse.model_validate(f, from_attributes=True) for f in result.scalars().all()]
|
||||
except Exception as e:
|
||||
log.warning(f'Error fetching pending files for knowledge {knowledge_id}: {e}')
|
||||
return []
|
||||
|
||||
async def delete_file_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
|
||||
async def delete_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
await db.execute(delete(File).filter_by(id=id))
|
||||
@@ -453,7 +399,7 @@ class FilesTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def delete_all_files(self, db: AsyncSession | None = None) -> bool:
|
||||
async def delete_all_files(self, db: Optional[AsyncSession] = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
await db.execute(delete(File))
|
||||
@@ -464,4 +410,4 @@ class FilesTable:
|
||||
return False
|
||||
|
||||
|
||||
Files = FilesTable() # singleton files repository
|
||||
Files = FilesTable()
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
import re
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func, select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select, or_, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,20 +64,6 @@ class FolderNameIdResponse(BaseModel):
|
||||
updated_at: int
|
||||
|
||||
|
||||
class SharedFolderResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str] = None
|
||||
user_id: str
|
||||
owner_name: Optional[str] = None
|
||||
permission: str = 'read'
|
||||
access_grants: list = []
|
||||
is_expanded: bool = False
|
||||
meta: Optional[dict] = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
@@ -144,56 +132,6 @@ class FolderTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_folder_by_id(
|
||||
self, id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FolderModel]:
|
||||
"""Fetch folder by ID only (no user_id filter). Used for shared access."""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Folder).filter_by(id=id))
|
||||
folder = result.scalars().first()
|
||||
if not folder:
|
||||
return None
|
||||
return FolderModel.model_validate(folder)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_shared_folder_ids_for_user(
|
||||
self, user_id: str, user_group_ids: set[str],
|
||||
db: Optional[AsyncSession] = None
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Returns {folder_id: highest_permission} for all folders shared with user.
|
||||
Checks direct user grants, group grants, and public (user:*) grants.
|
||||
"""
|
||||
from open_webui.models.access_grants import AccessGrant
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
conditions = [
|
||||
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'),
|
||||
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id),
|
||||
]
|
||||
if user_group_ids:
|
||||
conditions.append(
|
||||
and_(AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(user_group_ids))
|
||||
)
|
||||
result = await db.execute(
|
||||
select(AccessGrant).filter(
|
||||
AccessGrant.resource_type == 'folder',
|
||||
or_(*conditions),
|
||||
)
|
||||
)
|
||||
grants = result.scalars().all()
|
||||
|
||||
# Build {folder_id: highest_permission} ('write' > 'read')
|
||||
folder_perms = {}
|
||||
for g in grants:
|
||||
existing = folder_perms.get(g.resource_id)
|
||||
if existing != 'write':
|
||||
folder_perms[g.resource_id] = g.permission
|
||||
return folder_perms
|
||||
|
||||
async def get_children_folders_by_id_and_user_id(
|
||||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[list[FolderModel]]:
|
||||
@@ -252,25 +190,6 @@ class FolderTable:
|
||||
result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id))
|
||||
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
|
||||
|
||||
async def get_folder_ids_by_id_and_user_id_in_subtree(
|
||||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[str]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
|
||||
folder = result.scalars().first()
|
||||
if not folder:
|
||||
return []
|
||||
|
||||
folder_ids = [folder.id]
|
||||
folders = [FolderModel.model_validate(folder)]
|
||||
while folders:
|
||||
current_folder = folders.pop()
|
||||
children = await self.get_folders_by_parent_id_and_user_id(current_folder.id, user_id, db=db)
|
||||
folder_ids.extend(child.id for child in children)
|
||||
folders.extend(children)
|
||||
|
||||
return folder_ids
|
||||
|
||||
async def update_folder_parent_id_by_id_and_user_id(
|
||||
self,
|
||||
id: str,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user