Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe9a047dc8 | ||
|
|
dbdc8cd819 | ||
|
|
e724cf18b4 | ||
|
|
16b4c1a7ec | ||
|
|
fae047bb87 | ||
|
|
cf011d1365 | ||
|
|
0aa496876e | ||
|
|
caa9649f32 | ||
|
|
3870590704 | ||
|
|
a52ff4e052 | ||
|
|
035e816db6 | ||
|
|
f98f6fa08d | ||
|
|
ce82dfb912 | ||
|
|
5ded3189c1 | ||
|
|
f1b1d4731b | ||
|
|
e9fe586442 | ||
|
|
6d9f3fc8fb | ||
|
|
911b801883 | ||
|
|
a576cff69f | ||
|
|
cf0cef3f25 | ||
|
|
7b6430e20a | ||
|
|
611c19d2f4 | ||
|
|
2763e9f9fa | ||
|
|
3bb21c621c | ||
|
|
374ce6e7a8 | ||
|
|
e2ec6df3d4 | ||
|
|
38daedf131 | ||
|
|
1e1b92a1ba | ||
|
|
ef66d7bd3e | ||
|
|
f00d6408d8 | ||
|
|
6962fecf86 | ||
|
|
90d706c943 | ||
|
|
d17be56e1b | ||
|
|
94366938d1 | ||
|
|
e13ea3c92f | ||
|
|
da322f8c94 | ||
|
|
6c7e4d5b35 | ||
|
|
aa768f57dd | ||
|
|
f45da0e1ce | ||
|
|
d70dab56e5 | ||
|
|
772f1639a9 | ||
|
|
c01d8e3c12 | ||
|
|
96f19636e6 | ||
|
|
8146250274 | ||
|
|
00284d92da |
@@ -14,6 +14,10 @@ omit =
|
||||
*/node_modules/*
|
||||
*/static/*
|
||||
*/media/*
|
||||
*/htmlcov/*
|
||||
*/.claude/*
|
||||
restart_gallery.py
|
||||
standalone_tagger.py
|
||||
|
||||
branch = true
|
||||
parallel = true
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[flake8]
|
||||
max-line-length = 88
|
||||
extend-ignore = E203, W503
|
||||
exclude = reference/, .git, __pycache__, web/, venv/, htmlcov/, build/, dist/, .claude/, docs/, standalone_tagger.py
|
||||
per-file-ignores =
|
||||
tests/*:E402
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": [
|
||||
"py3.10 on ubuntu-latest",
|
||||
"py3.11 on ubuntu-latest",
|
||||
"py3.12 on ubuntu-latest",
|
||||
"py3.13 on ubuntu-latest",
|
||||
"py3.10 on windows-latest",
|
||||
"py3.11 on windows-latest",
|
||||
"py3.12 on windows-latest",
|
||||
"py3.13 on windows-latest",
|
||||
"py3.10 on macos-latest",
|
||||
"py3.11 on macos-latest",
|
||||
"py3.12 on macos-latest",
|
||||
"py3.13 on macos-latest",
|
||||
"coverage gate (85%)",
|
||||
"black and flake8",
|
||||
"frontend modules",
|
||||
"viewerjs",
|
||||
"real ComfyUI compatibility"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": null,
|
||||
"restrictions": null,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false
|
||||
}
|
||||
@@ -46,3 +46,15 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Maintainer browser test dependencies; Viewer.js itself has an asset-aware updater.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tools/vendor"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
allow:
|
||||
- dependency-name: "@playwright/test"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "deps(vendor-tools)"
|
||||
|
||||
@@ -14,15 +14,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-quality-${{ hashFiles('requirements-dev.txt') }}
|
||||
@@ -48,10 +48,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'ComfyAssets' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
|
||||
@@ -9,28 +9,28 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-${{ matrix.python-version }}-
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -38,4 +38,115 @@ jobs:
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: python -m unittest discover tests/ -v
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
comfyui:
|
||||
name: real ComfyUI compatibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- name: Read the reviewed runtime pins
|
||||
id: runtime
|
||||
run: |
|
||||
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
|
||||
import runtime from './tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
|
||||
JS
|
||||
- name: Check out pinned ComfyUI
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.runtime.outputs.repository }}
|
||||
ref: ${{ steps.runtime.outputs.revision }}
|
||||
path: .ci-comfyui
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ steps.runtime.outputs.pythonVersion }}
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements-dev.txt
|
||||
.ci-comfyui/requirements.txt
|
||||
tools/vendor/comfyui-runtime.json
|
||||
- name: Install CPU runtime and test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
- name: Classic and Nodes 2.0 startup and compatibility
|
||||
env:
|
||||
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
|
||||
PM_TEST_PYTHON: python
|
||||
run: npm run --prefix tools/vendor test:comfyui
|
||||
- name: Upload real-host reports and startup logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: real-comfyui-reports
|
||||
path: tools/vendor/test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
coverage:
|
||||
name: coverage gate (85%)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Measure coverage
|
||||
run: |
|
||||
python -m coverage run -m unittest discover -s tests
|
||||
python -m coverage combine
|
||||
python -m coverage report --fail-under=85
|
||||
|
||||
lint:
|
||||
name: black and flake8
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install black flake8
|
||||
- run: black --check .
|
||||
- run: flake8 .
|
||||
|
||||
js-test:
|
||||
name: frontend modules
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Check shipped extension and page modules parse
|
||||
run: |
|
||||
shopt -s globstar nullglob
|
||||
for f in web/comfy/**/*.js web/comfy/**/*.mjs web/js/**/*.js web/js/**/*.mjs; do
|
||||
node --input-type=module --check < "$f" || exit 1
|
||||
done
|
||||
|
||||
- name: Run JS tests
|
||||
run: node --test tests/js/*.test.js
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Viewer.js vendor and compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
viewerjs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
- run: python -m pip install -r requirements-dev.txt
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npm run --prefix tools/vendor check
|
||||
- run: npm run --prefix tools/vendor test
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
|
||||
- name: Frontend unit, viewer, workflow, and coverage checks
|
||||
run: npm run --prefix tools/vendor test:coverage
|
||||
- name: Firefox and WebKit page workflows
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
- name: Upload browser traces and frontend coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/vendor/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/**
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Update vendored Viewer.js
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 7 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Stable version to vendor; leave blank for the latest npm release'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: viewerjs-update
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Update the dependency and regenerate assets
|
||||
env:
|
||||
VIEWER_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$VIEWER_VERSION" ]; then
|
||||
npm run --prefix tools/vendor update -- --version "$VIEWER_VERSION"
|
||||
else
|
||||
npm run --prefix tools/vendor update
|
||||
fi
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet -- tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs; then
|
||||
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.changes.outputs.changed == 'true'
|
||||
run: |
|
||||
npm run --prefix tools/vendor check
|
||||
npm run --prefix tools/vendor test
|
||||
node --test tests/js/*.test.js
|
||||
npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
npm run --prefix tools/vendor test:browser
|
||||
- name: Open the update PR
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
version=$(node -p "require('./web/lib/viewerjs/provenance.json').version")
|
||||
branch="deps/viewerjs-$version"
|
||||
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --state all --head "$branch" --json number --jq 'length')
|
||||
if [ "$existing" != '0' ]; then
|
||||
echo "A PR already exists for Viewer.js $version; leaving it for review."
|
||||
exit 0
|
||||
fi
|
||||
remote=$(git ls-remote origin "refs/heads/$branch")
|
||||
if [ -z "$remote" ]; then
|
||||
git switch -c "$branch"
|
||||
git add tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs
|
||||
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "deps: update vendored Viewer.js to $version"
|
||||
git push origin "HEAD:refs/heads/$branch"
|
||||
fi
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base "$BASE_BRANCH" --head "$branch" --title "deps: update vendored Viewer.js to $version" --body-file tools/vendor/update-pr.md
|
||||
gh workflow run viewerjs-check.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
|
||||
@@ -9,6 +9,13 @@ AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Local files
|
||||
# Local PromptManager state (config.json holds API keys and machine paths)
|
||||
config.json
|
||||
resume.sh
|
||||
*.backup_*
|
||||
promptmanager_logs/
|
||||
custom_data/
|
||||
data/
|
||||
docs/
|
||||
logs/
|
||||
standalone_tagger.py
|
||||
@@ -193,3 +200,14 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Maintainer-only JavaScript tooling and browser test artifacts
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Combined frontend coverage reports
|
||||
coverage/
|
||||
|
||||
# Disposable upstream checkout for real-host CI
|
||||
.ci-comfyui/
|
||||
|
||||
@@ -9,7 +9,7 @@ repos:
|
||||
|
||||
- id: flake8
|
||||
name: flake8
|
||||
entry: flake8 --select=E9,F63,F7,F82 --show-source --exclude=reference/,web/
|
||||
entry: flake8 --show-source
|
||||
language: system
|
||||
types: [python]
|
||||
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||

|
||||

|
||||
|
||||
A comprehensive ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, automatic image gallery system, and powerful ComfyUI workflow metadata analysis using SQLite.
|
||||
|
||||
## 📋 A Note on v2 Development
|
||||
|
||||
V2 development will take longer as life has taken me in other directions for the moment. I will still work on it but not as actively as before. In the meantime, I will backport some of the features users have requested from v2 over to v1. Once v2 is ready, I will update here. Thank you for your continued support!
|
||||
|
||||
---
|
||||
A comprehensive ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, automatic image gallery system, folder-based organization, LoRA Manager integration, and powerful ComfyUI workflow metadata analysis using SQLite.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -54,6 +48,7 @@ Both nodes include the complete PromptManager feature set:
|
||||
|
||||
- **💾 Persistent Storage**: Automatically saves all prompts to a local SQLite database
|
||||
- **🔍 Advanced Search**: Query past prompts with text search, category filtering, and metadata
|
||||
- **📁 Folder Filter**: Browse and filter prompts by output subdirectory
|
||||
- **🖼️ Automatic Image Gallery**: Automatically links generated images to their prompts
|
||||
- **🏷️ Rich Metadata**: Add categories, tags, ratings, notes, and workflow names to prompts
|
||||
- **🚫 Duplicate Prevention**: Uses SHA256 hashing to detect and prevent duplicate storage
|
||||
@@ -62,7 +57,8 @@ Both nodes include the complete PromptManager feature set:
|
||||
- **🔬 Workflow Analysis**: Extract and analyze ComfyUI workflow data from PNG images
|
||||
- **📋 Metadata Viewer**: Standalone tool for analyzing ComfyUI-generated images
|
||||
- **🛠️ System Management**: Built-in diagnostics, backup/restore, and maintenance tools
|
||||
- **🏷️ AI AutoTag**: Automatically tag your image collection using JoyCaption vision models
|
||||
- **🏷️ AI AutoTag**: Automatically tag your image collection using WD14 or JoyCaption vision models
|
||||
- **🔗 LoRA Manager Integration**: Import LoRA metadata and preview images from [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager)
|
||||
|
||||

|
||||
|
||||
@@ -143,6 +139,35 @@ The comprehensive gallery system provides complete management of all ComfyUI out
|
||||
|
||||
_Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professional image viewing capabilities._
|
||||
|
||||
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
|
||||
for reproducible updates, verification, and automated update PRs.
|
||||
|
||||
Prompt Manager appears as **Prompt Manager (Classic)** in the Add Node menu.
|
||||
It keeps the existing layout and behavior while using shared
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) controls
|
||||
that follow ComfyUI's light/dark theme. Existing workflows keep the same internal
|
||||
node identifier and sockets. The reusable architecture is inspired by
|
||||
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
|
||||
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
|
||||
|
||||
**Prompt Manager (Focused)** adds an editor-first layout with collapsible
|
||||
composition and metadata fields. Open **Browse library** to search saved prompts,
|
||||
filter by saved category and comma-separated tags, preview a selection, then choose
|
||||
**Use prompt**; **Load metadata** controls whether
|
||||
category and tags come with it. Metadata fields suggest saved categories and tags;
|
||||
choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the
|
||||
next tag; category remains a single value. New values can still be typed freely.
|
||||
Prepend and append STRING sockets remain visible
|
||||
when the fields are collapsed, so LoRA trigger outputs can connect directly.
|
||||
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
|
||||
support ComfyUI Classic and Nodes 2.0.
|
||||
|
||||
**Prompt Manager (Split)** keeps the same editor and library visible together.
|
||||
Wide nodes place the library beside the editor; narrow nodes stack the panels.
|
||||
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
|
||||
workflow persistence, and connectable prepend/append inputs. Encoding and storage
|
||||
match Classic and Focused.
|
||||
|
||||
### 🔬 ComfyUI Workflow Analysis
|
||||
|
||||
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
||||
@@ -168,7 +193,7 @@ _Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professiona
|
||||
|
||||
- **Full-text search** across all stored prompts
|
||||
- **Category filtering** for organized prompt collections
|
||||
- **Tag-based search** with support for multiple tags
|
||||
- **Tag-based search** with support for multiple tags, with autocomplete that narrows to tags seen together with the ones already typed
|
||||
- **Rating filters** to find your best prompts
|
||||
- **Date range filtering** for temporal searches
|
||||
- **Recent prompts** quick access
|
||||
@@ -310,9 +335,12 @@ The comprehensive gallery system provides complete access to all your ComfyUI ou
|
||||
|
||||
Configure your viewing experience in the settings panel:
|
||||
|
||||
- **Performance Settings**: Grid columns, image quality, lazy loading
|
||||
- **Display Settings**: Default view, items per page, grid columns, media info, infinite scrolling
|
||||
- **Performance Settings**: Image quality, lazy loading
|
||||
- **Video Settings**: Autoplay, mute by default, loop videos
|
||||
- **Thumbnail Management**: Generate optimized thumbnails for faster loading
|
||||
- **Thumbnail Management**: Generate optimized thumbnails for faster loading (runs on the configured worker threads; a run that stops at the per-run cap tells you how many remain)
|
||||
- **Find Duplicate Images**: Hashes every file on the worker threads with a progress bar; close the window and it keeps running, results wait in a pill
|
||||
- **Rescan Output Folder**: Drops the server-side listing cache and rewalks the folders
|
||||
|
||||
#### **🖼️ Image Viewing Experience**
|
||||
|
||||
@@ -362,20 +390,23 @@ Use the standalone metadata viewer to analyze any ComfyUI-generated PNG:
|
||||
Import existing ComfyUI images into your database:
|
||||
|
||||
1. **Open admin dashboard** and click **"📸 Scan Images"**
|
||||
2. **Start scan** to analyze your entire output directory
|
||||
3. **Monitor progress** with real-time status updates
|
||||
2. **Start scan** to analyze every configured output directory
|
||||
3. **Monitor progress** with real-time status updates. The scan runs on the server: closing the window only minimizes it to a progress pill, and reloading the page re-attaches to a running scan
|
||||
4. **Review results** showing:
|
||||
- Images found and processed
|
||||
- Prompts extracted and linked
|
||||
- Any errors or issues encountered
|
||||
5. **Access imported data** through the normal gallery interface
|
||||
|
||||
The scan reads PNG text chunks and A1111-style parameters, and, when `ffprobe` is installed, the prompt ComfyUI's video savers embed in MP4/MOV/WebM files. Files are read on the number of **worker threads** set in Settings → Performance (default: half your cores), which makes a large library several times faster.
|
||||
|
||||
### 🏷️ AI AutoTag
|
||||
|
||||
Automatically tag your entire image collection using JoyCaption vision models:
|
||||
Automatically tag your entire image collection using AI vision models:
|
||||
|
||||
#### **Model Options**
|
||||
|
||||
- **WD14 SwinV2 / WD14 ViT**: Fast ONNX-based classifiers (~400MB) that output Danbooru tags with confidence scores — no prompt needed, adjustable confidence thresholds
|
||||
- **JoyCaption Beta One FP16**: Full precision model for highest quality tagging (requires more VRAM)
|
||||
- **JoyCaption Beta One GGUF (FP8)**: Quantized model for lower VRAM usage with minimal quality loss
|
||||
|
||||
@@ -417,6 +448,61 @@ Adjust the system prompt to match your tagging style:
|
||||
- Models are downloaded automatically on first use
|
||||
- Feature requests and improvements are welcome!
|
||||
|
||||
### 📁 Folder Filter
|
||||
|
||||

|
||||
|
||||
Organize and filter your prompt library by output subdirectory. The dashboard search panel includes a **Folder** dropdown that lists all subdirectories where your images are stored.
|
||||
|
||||
- **Automatic Detection**: Folders are extracted from your image paths — no configuration needed
|
||||
- **Multi-Directory Support**: Works across all configured gallery scan directories
|
||||
- **Quick Filtering**: Select a folder from the dropdown to instantly filter prompts to that subdirectory
|
||||
|
||||
> **Note:** If you have an existing prompt library from before the folder feature was added, you will need to **rescan your image library** (click **Scan Images** in the admin dashboard) to populate folder data for your existing prompts.
|
||||
|
||||
### 🔗 LoRA Manager Integration
|
||||
|
||||

|
||||
|
||||
If you use [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager), PromptManager can import your LoRA metadata, trigger words, and example images directly into your prompt database. This lets you search, tag, and browse your LoRA collection alongside your regular prompts.
|
||||
|
||||
> **WIP:** LoRA Manager support is a work in progress — this was a highly requested feature. Please [open issues](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues) for any bugs or feature requests.
|
||||
|
||||
#### **Enabling the Integration**
|
||||
|
||||
1. Open the admin dashboard and click **Settings**
|
||||
2. Scroll to the **Integrations** section — PromptManager will auto-detect if LoRA Manager is installed
|
||||
|
||||

|
||||
|
||||
3. Toggle the **LoraManager** switch to enable
|
||||
4. Optionally enable **Auto-inject trigger words** to automatically append LoRA trigger words when `<lora:name:weight>` is detected in your prompts
|
||||
5. Click **Save Settings** (requires a ComfyUI restart to take effect)
|
||||
|
||||
#### **Importing LoRA Data**
|
||||
|
||||
Click **Import LoRA Data** in the settings panel to start the import. A progress popup shows real-time status as each LoRA is processed:
|
||||
|
||||
- Scans all LoRA directories (including `extra_model_paths.yaml` paths) for metadata
|
||||
- Downloads example images from CivitAI as 512px thumbnails
|
||||
- Creates a prompt entry for each LoRA with trigger words, example prompts, and preview images
|
||||
- All imported LoRAs are tagged with `lora-manager` category for easy filtering
|
||||
|
||||

|
||||
|
||||
After import, filter by the `lora-manager` category to browse your LoRA collection.
|
||||
|
||||
#### **CivitAI API Key (Optional)**
|
||||
|
||||
A CivitAI API key is **required if your library contains NSFW LoRAs** — CivitAI blocks unauthenticated access to NSFW preview images. Without a key, only SFW preview images are downloaded.
|
||||
|
||||
To add your key:
|
||||
1. Get your API key from [civitai.com/user/account](https://civitai.com/user/account)
|
||||
2. Paste it into the **CivitAI API Key** field in the Integrations settings
|
||||
3. Save and re-import to download any previously skipped images
|
||||
|
||||
> **Note:** Re-importing is safe — PromptManager skips LoRAs that were already imported. To do a fresh import, the previous `lora-manager` data is cleared automatically before re-scanning.
|
||||
|
||||
### 🌐 Web Interface Features
|
||||
|
||||
The comprehensive web interface provides:
|
||||
@@ -600,8 +686,9 @@ CREATE TABLE generated_images (
|
||||
- **`prompt_search_list.py`** - Batch search node implementation (LIST output)
|
||||
- **`database/models.py`** - Database schema and connection management
|
||||
- **`database/operations.py`** - CRUD operations and search functionality
|
||||
- **`py/api.py`** - Web API endpoints for the interface
|
||||
- **`py/api/`** - Web API route modules (prompts, images, tags, lora integration, etc.)
|
||||
- **`py/config.py`** - Configuration management
|
||||
- **`py/lora_utils.py`** - LoRA Manager integration utilities
|
||||
- **`utils/hashing.py`** - SHA256 hashing for deduplication
|
||||
- **`utils/validators.py`** - Input validation and sanitization
|
||||
- **`utils/image_monitor.py`** - Automatic image detection system
|
||||
@@ -611,7 +698,9 @@ CREATE TABLE generated_images (
|
||||
- **`utils/diagnostics.py`** - System diagnostics and health checks
|
||||
- **`web/admin.html`** - Advanced admin dashboard with metadata panel
|
||||
- **`web/index.html`** - Simple web interface
|
||||
- **`web/prompt_manager.js`** - JavaScript functionality
|
||||
- **`web/comfy/prompt_manager.js`** - ComfyUI canvas extension (the only file ComfyUI loads)
|
||||
- **`web/js/admin.js`**, **`web/js/gallery.js`** - Dashboard and gallery JavaScript
|
||||
- **`web/js/tags-page.js`** - Tag management JavaScript
|
||||
- **`web/metadata.html`** - Standalone PNG metadata viewer
|
||||
|
||||
### File Structure
|
||||
@@ -628,8 +717,9 @@ ComfyUI_PromptManager/
|
||||
│ └── operations.py # Database operations
|
||||
├── py/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py # Web API endpoints
|
||||
│ └── config.py # Configuration
|
||||
│ ├── api/ # Web API route modules
|
||||
│ ├── config.py # Configuration
|
||||
│ └── lora_utils.py # LoRA Manager integration
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── hashing.py # Hashing utilities
|
||||
@@ -641,9 +731,15 @@ ComfyUI_PromptManager/
|
||||
│ └── diagnostics.py # System diagnostics
|
||||
├── web/
|
||||
│ ├── admin.html # Advanced admin dashboard
|
||||
│ ├── gallery.html # Image gallery
|
||||
│ ├── index.html # Simple web interface
|
||||
│ ├── metadata.html # Standalone metadata viewer
|
||||
│ └── prompt_manager.js # JavaScript functionality
|
||||
│ ├── comfy/
|
||||
│ │ └── prompt_manager.js # ComfyUI canvas extension (WEB_DIRECTORY)
|
||||
│ └── js/
|
||||
│ ├── admin.js # Dashboard JavaScript
|
||||
│ ├── gallery.js # Gallery JavaScript
|
||||
│ └── tags-page.js # Tag management JavaScript
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ └── test_basic.py # Test suite
|
||||
@@ -705,11 +801,15 @@ Configure the web interface behavior:
|
||||
# Web UI settings (PromptManagerConfig class)
|
||||
RESULT_TIMEOUT = 5 # Seconds to auto-hide results in ComfyUI node
|
||||
WEBUI_DISPLAY_MODE = 'newtab' # 'popup' or 'newtab' for Web UI button
|
||||
INFINITE_SCROLL = False # Dashboard list loads the next page on scroll by default
|
||||
SHOW_TEST_BUTTON = False # Show API test button in node UI
|
||||
```
|
||||
|
||||
All of these are editable in the dashboard Settings modal and saved to `config.json` (which is private to the installation and holds the CivitAI key, so it is never committed).
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
- **Worker threads** (Settings → Performance): threads used by the image rescan, thumbnail generation and duplicate detection. Default is half the detected cores, maximum is the core count. Pillow releases the GIL while decoding, so the scan and thumbnails scale almost linearly; keep a few cores free while ComfyUI is generating.
|
||||
- The database automatically creates indexes for optimal search performance
|
||||
- Regular `VACUUM` operations keep the database optimized
|
||||
- Consider backing up the database periodically
|
||||
@@ -752,10 +852,15 @@ db.model.backup_database("backup_prompts.db")
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd KikoTextEncode
|
||||
python -m pytest tests/ -v
|
||||
cd ComfyUI_PromptManager
|
||||
pip install -r requirements-dev.txt
|
||||
python -m unittest discover -s tests # Python (what CI runs)
|
||||
node --test tests/js/*.test.js # frontend pure modules
|
||||
python -m coverage run -m unittest discover -s tests && python -m coverage combine && python -m coverage report
|
||||
```
|
||||
|
||||
CI runs the suite on Linux, Windows and macOS for Python 3.10 to 3.12 with an 85% coverage gate. Tests that delete a database file first check that nothing in the process still holds it open (`tests/open_handles.py`), so a connection leak fails on Linux the way it would on Windows.
|
||||
|
||||
### Code Style
|
||||
|
||||
The project follows PEP 8 guidelines with:
|
||||
@@ -797,7 +902,7 @@ The project follows PEP 8 guidelines with:
|
||||
For debugging, you can enable verbose logging in the node:
|
||||
|
||||
```python
|
||||
# Add to kiko_text_encode.py
|
||||
# Add to prompt_manager.py
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
```
|
||||
@@ -814,15 +919,19 @@ MIT License - see LICENSE file for details.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Completed in v3.0.0
|
||||
### Recently Completed
|
||||
|
||||
- **✅ PNG Metadata Analysis**: Complete ComfyUI workflow extraction from images
|
||||
- **✅ Standalone Metadata Viewer**: Dedicated tool for analyzing any ComfyUI image
|
||||
- **✅ Advanced Admin Dashboard**: Comprehensive management interface with modern UI
|
||||
- **✅ Integrated Metadata Panel**: Real-time workflow analysis in image viewer
|
||||
- **✅ Bulk Image Scanning**: Mass import of existing ComfyUI images
|
||||
- **✅ System Management Tools**: Backup, restore, diagnostics, and maintenance
|
||||
- **✅ Enhanced Error Handling**: Robust PNG parsing with NaN value cleaning
|
||||
- **✅ Security & Reliability Hardening (3.3.0)**: validated gallery roots, contained file serving, safe backups and restores, background scans, 85%+ test coverage on all three operating systems
|
||||
- **✅ Worker Threads**: scan, thumbnails and duplicate detection run on a configurable thread pool
|
||||
- **✅ Tag Autocomplete & Infinite Scrolling**: faster browsing in the dashboard and gallery
|
||||
- **✅ Queue-Time Run Counting (3.2.4)**: run counts and image linking follow the ComfyUI queue
|
||||
- **✅ LoRA Manager Integration**: Import LoRA metadata, trigger words, and preview images
|
||||
- **✅ Folder Filter**: Browse and filter prompts by output subdirectory
|
||||
- **✅ Multi-Directory Gallery**: Scan multiple output directories simultaneously
|
||||
- **✅ WD14 Tagger**: Fast ONNX-based auto-tagging with Danbooru tags
|
||||
- **✅ Tailwind v4 Migration**: ComfyUI theme token system for consistent styling
|
||||
- **✅ Tag Management Page**: Dedicated page for tag search, rename, merge, and delete
|
||||
- **✅ Junction Table Tags**: Normalized tag storage with proper foreign keys
|
||||
|
||||
### Planned Features
|
||||
|
||||
@@ -830,19 +939,53 @@ MIT License - see LICENSE file for details.
|
||||
- **🤝 Collaboration**: Share prompt collections with other users
|
||||
- **🧠 AI Suggestions**: Recommend similar prompts based on metadata analysis
|
||||
- **📈 Advanced Analytics**: Detailed usage statistics and trends with workflow insights
|
||||
- **🔌 Plugin System**: Support for third-party extensions and custom analyzers
|
||||
- **🎨 Enhanced Batch Processing**: Advanced bulk operations with metadata editing
|
||||
- **🔄 Workflow Templates**: Save and reuse common workflow patterns
|
||||
- **📊 Visual Analytics**: Charts and graphs for prompt effectiveness analysis
|
||||
|
||||
### Integration Ideas
|
||||
|
||||
- **Workflow linking**: Connect prompts to specific workflow templates
|
||||
- **Image analysis**: Analyze generated images to improve suggestions
|
||||
- **Version control**: Track prompt iterations and effectiveness
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3.3.1 (Proxy Routing Fix)
|
||||
|
||||
- **🌐 Proxy support**: the dashboard, gallery, tag page and metadata viewer now work when ComfyUI is reached through a proxy mount such as SwarmUI's `/ComfyBackendDirect/`. Scripts, images, API calls, progress streams and gallery folder filters stay under the mount, and the canvas node's buttons use ComfyUI's `fileURL()`. Direct ComfyUI URLs are unchanged. Thanks to @ghosttigerllc-bit ([#164](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/164))
|
||||
- **📦 Dependencies**: the runtime minimums stay at `Pillow>=8.0.0` and `watchdog>=2.1.0`, so updating Prompt Manager never forces upgrades in the shared ComfyUI environment
|
||||
|
||||
### v3.3.0 (Hardening, Background Jobs & Worker Threads)
|
||||
|
||||
- **🔒 Security**: gallery roots restricted to ComfyUI directories and validated wherever they are read; media-only, fail-closed image serving; Windows path escape in static routes closed; quote-safe HTML escaping with inline handlers moved to data attributes; the CivitAI key is never returned and only sent to HTTPS CivitAI hosts; auto-tag paths contained; side-effect routes are POST-only; backups include the WAL and restores are size-capped, integrity-checked and applied through SQLite's online backup API
|
||||
- **🧵 Worker Threads**: a Settings slider (default half the cores) used by the image rescan, thumbnail generation and duplicate detection. Measured: a 1 GB sample scanned in 2.5 s instead of 18 s at 8 threads
|
||||
- **🔁 Background Jobs**: the image rescan and the duplicate scan run on the server, stream progress, survive a closed tab, minimize to a progress pill and re-attach after a reload
|
||||
- **🎬 Video Prompts**: the scan reads the prompt ComfyUI's video savers embed in MP4/MOV/WebM containers (needs `ffprobe`)
|
||||
- **🖼️ Thumbnails**: written atomically and served under versioned URLs so a regenerated thumbnail never shows a stale cached copy; the per-run cap applies only to thumbnails that still need generating and the gallery says how many remain
|
||||
- **🏷️ Tag Autocomplete**: the dashboard tag box suggests tags, narrowed to those that co-occur with the tags already typed
|
||||
- **♾️ Infinite Scrolling**: optional in the gallery and the dashboard list (saved default plus a session toggle)
|
||||
- **⚙️ Gallery Settings** now apply at load; grid columns, media info and the Rescan button work as labelled
|
||||
- **🐛 Correctness**: run counts increment when ComfyUI dequeues a prompt; negative prompts are never traced; per-thread SQLite connections and idempotent migrations (an old rebuild could delete every image row); `truncate_logs` no longer rolls the log over; the ComfyUI canvas loads only the canvas extension; Windows console banners are ASCII-safe; subfolders use `/` on every platform
|
||||
- **🧪 Tests & CI**: 1,460 Python and 142 JavaScript tests (from 510 and 26), coverage 96% with an 85% gate, CI on Linux, Windows and macOS for Python 3.10–3.12, flake8 clean
|
||||
- **⚠️ API changes**: image paths in responses are relative to the ComfyUI tree (use `url`); `autotag/download`, `autotag/start` and the thumbnail progress stream are POST-only; `lora/status` returns `has_civitai_api_key` instead of the key; `/search` is paginated
|
||||
|
||||
### v3.2.4 (Recently Used Sorting & Queue-Time Run Counts)
|
||||
|
||||
- **🕒 Recently Used Sort**: sort the dashboard by last use, most used or rating, server-side across pages
|
||||
- **🔢 Run Counts**: `run_count` and `last_used_at` are recorded through the ComfyUI queue hook, so cached nodes still count
|
||||
- **🔗 Positive-Prompt Image Linking**: generated images link to the positive prompt traced through the queued graph
|
||||
|
||||
### v3.2.1 (LoRA Manager Integration)
|
||||
|
||||
- **🔗 LoRA Manager Integration**: Import LoRA metadata, trigger words, and CivitAI example images from [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) into your prompt database
|
||||
- **💉 Auto-Inject Trigger Words**: Optionally append LoRA trigger words when `<lora:name:weight>` is detected in prompts during encoding
|
||||
- **🔑 CivitAI API Key Support**: Authenticate with CivitAI to download NSFW preview images
|
||||
- **📥 Import Progress Modal**: Real-time SSE streaming progress during LoRA import with per-model status
|
||||
|
||||
> **Note:** LoRA Manager support is a WIP — this was a highly requested feature. Please [open issues](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues) for any bugs or feature requests.
|
||||
|
||||
### v3.2.0 (Folder Filter & QoL Improvements)
|
||||
|
||||
- **📁 Folder Filter**: New folder dropdown in the search panel to filter prompts by output subdirectory
|
||||
- **📂 Multi-Directory Gallery Scan**: Configure multiple output directories and browse them all in one gallery
|
||||
- **🖼️ Filmstrip Prompt Display**: Show prompt text and copy button in the filmstrip image viewer
|
||||
- **🖱️ Click-to-Close Viewer**: Click outside the image viewer to close it
|
||||
- **🐛 Bug Fixes**: Fixed database path config, UnboundLocalError in text inputs, node caching skip, diagnostics singleton lifecycle
|
||||
|
||||
### v3.1.0 (WD14 Tagger, Tailwind v4 & Major Refactors)
|
||||
|
||||
- **🏷️ WD14 Tagger Support**: Added WD14 SwinV2 and WD14 ViT as new auto-tag model options — fast ONNX-based classifiers (~400MB) that output Danbooru tags with confidence scores, no prompt needed
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Testing and merge confidence
|
||||
|
||||
Routine changes should merge after the required checks pass and the diff is
|
||||
reviewed. Add a regression test for each bug; prove it fails before the fix.
|
||||
|
||||
## What runs
|
||||
|
||||
| Layer | Real components | Replaced boundaries |
|
||||
| --- | --- | --- |
|
||||
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
|
||||
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
|
||||
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
|
||||
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
|
||||
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
|
||||
|
||||
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
|
||||
identity, Tags selection and viewer cleanup, delayed responses, auto-tag review
|
||||
and persistence, and infinite scrolling without replacing existing cards.
|
||||
Data safety workflows download and upload actual SQLite backups, verify restored
|
||||
prompts/tags/image links, reject corrupt uploads without changing live data, and
|
||||
exercise cancellation and network-failure recovery for restores and deletion.
|
||||
Duplicate cleanup verifies only the selected file disappears; prompt deletion
|
||||
verifies the other prompt and original image files survive.
|
||||
They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU,
|
||||
model downloads, running ComfyUI instance, or personal configuration is required.
|
||||
The server binds to localhost and creates disposable directories and a database;
|
||||
its reset endpoint exists only in the test harness.
|
||||
|
||||
## Run locally
|
||||
|
||||
Use a Python environment containing `requirements-dev.txt` and Node.js 24:
|
||||
|
||||
```sh
|
||||
python -m unittest discover -s tests -q
|
||||
npm ci --prefix tools/vendor --ignore-scripts
|
||||
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
|
||||
npm run --prefix tools/vendor test
|
||||
npm run --prefix tools/vendor test:coverage
|
||||
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
```
|
||||
|
||||
`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local
|
||||
port (default 8765). Set these environment variables when Python is outside PATH
|
||||
or the port is occupied. Test servers are never reused. On unsupported Linux
|
||||
distributions, browser system dependencies may need manual installation; CI uses
|
||||
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
## Managed real ComfyUI
|
||||
|
||||
The shared engine comes from an exact commit of
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit), recorded
|
||||
in `tools/vendor/package.json` and its integrity-checked lockfile. The local
|
||||
adapter sets PromptManager's database/configuration and monitoring policy;
|
||||
source discovery, coverage mapping/gates and process lifecycle are shared code.
|
||||
KikoTools remains the reference and is unchanged by this integration.
|
||||
|
||||
Use a **dedicated disposable** ComfyUI checkout at the revision in
|
||||
`tools/vendor/comfyui-runtime.json`. With a separate Python environment, install
|
||||
that checkout's requirements, this repo's `requirements-dev.txt`, and the exact
|
||||
CPU torch/torchvision pins from the JSON (using PyTorch's CPU wheel index).
|
||||
Install Chromium using the Playwright command above, then run:
|
||||
|
||||
```sh
|
||||
PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
|
||||
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
|
||||
npm run --prefix tools/vendor test:comfyui
|
||||
```
|
||||
|
||||
CI performs this setup automatically. `PM_COMFY_URL` can select another
|
||||
`http://127.0.0.1:PORT/` address (default port 8189). This is an owned managed
|
||||
server: a running host is never reused. Do not point it at your live checkout.
|
||||
The launcher verifies the upstream SHA and clean tracked files, snapshots only
|
||||
tracked PromptManager files, and creates temporary user/input/output/database/
|
||||
configuration/log directories. Personal untracked files are excluded. It runs
|
||||
CPU/offline with only PromptManager enabled and removes owned state on shutdown.
|
||||
The upstream checkout must also be disposable: ComfyUI may initialize local
|
||||
runtime files there. Browser host-settings writes are intercepted; actual
|
||||
PromptManager routes use the disposable host and database.
|
||||
|
||||
`real-comfyui-reports` retains startup logs, version attachments, screenshots and
|
||||
traces for 14 days. Update upstream/frontend/CPU pins together after reviewing
|
||||
and running both startup projects; never replace the SHA with a moving branch.
|
||||
Update the toolkit via an exact reviewed commit archive and commit both npm
|
||||
manifest and lockfile. No toolkit or npm installation is needed by end users.
|
||||
|
||||
## Coverage and failures
|
||||
|
||||
Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges
|
||||
Node V8 unit coverage with Chromium page-workflow coverage through c8. Every
|
||||
`web/js/**/*.js` and `web/js/**/*.mjs` file appears, including untouched files at zero coverage. The
|
||||
controller tests provide V8 filenames so their executed code is counted too.
|
||||
|
||||
`tools/vendor/frontend-coverage-baseline.json` holds a floor for every file and
|
||||
metric. CI rejects regressions, missing measurements, and new files without a
|
||||
reviewed baseline. These floors reflect current coverage, with two percentage
|
||||
points of allowance for runtime/timing differences; they do not claim 80% coverage
|
||||
of the large controllers. New files need at least 80% line coverage when added via
|
||||
the baseline updater. Raise floors with `npm run --prefix tools/vendor
|
||||
coverage:update`; it reruns the suites and cannot lower an existing floor.
|
||||
Review baseline changes like code changes.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. GitHub
|
||||
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
|
||||
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
|
||||
|
||||
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
|
||||
ways, actual database prompt selection, execution-text serialization, workflow
|
||||
reload, widget/style cleanup, node schemas and standalone-page opening. It does
|
||||
not queue inference or certify every existing feature, arbitrary host versions,
|
||||
GPU/model changes, cross-pack installation or visual design changes. Those still
|
||||
need targeted validation.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
`.github/branch-protection.json` records the required checks: all twelve Python/OS
|
||||
combinations, Python coverage, formatting, frontend modules, `viewerjs`, and `real ComfyUI compatibility`.
|
||||
The branch must be current with main, and administrators follow the same checks.
|
||||
It does not require an additional human approval.
|
||||
|
||||
A repository administrator applies the configuration after the new workflow is
|
||||
passing, then verifies it:
|
||||
|
||||
```sh
|
||||
gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
|
||||
--input .github/branch-protection.json
|
||||
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks
|
||||
```
|
||||
|
||||
Update the manifest whenever required job names change. Keep the frontend check
|
||||
free of path filters so a required check is never silently skipped.
|
||||
|
||||
## Unified workspace shell
|
||||
|
||||
The opt-in `/prompt_manager/workspace` page uses the shipped toolkit and real
|
||||
`/prompt_manager/stats` API. Existing admin, gallery and metadata URLs remain
|
||||
available while replacement workspaces are developed. Browser smoke tests in
|
||||
`tests/browser/workspace.spec.mjs` cover navigation/history, keyboard skip links,
|
||||
mobile layout, theme persistence/fallback, loading/retry and stale responses.
|
||||
They run automatically in the existing four-browser-project workflow and frontend
|
||||
coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
|
||||
managed host with both renderer startup configurations.
|
||||
|
||||
Run the focused page checks with:
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs
|
||||
node --test tests/js/workspace-*.test.js
|
||||
```
|
||||
|
||||
Use the disposable fixture and managed-host prerequisites above. A maintainer
|
||||
live check needs a ComfyUI restart after adding the Python route, then opening
|
||||
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
|
||||
@@ -3,11 +3,14 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc
|
||||
with persistent prompt storage, advanced search capabilities, and an automatic image
|
||||
gallery system.
|
||||
|
||||
This module provides two main node types:
|
||||
This module provides prompt node types:
|
||||
- PromptManager: CLIP encoding node that outputs CONDITIONING
|
||||
- PromptManagerText: Text-only node that outputs STRING with prepend/append functionality
|
||||
- PromptManagerFocused: Editor-first layout using the same encoding contract
|
||||
- PromptManagerSplit: Side-by-side editor and library using the same contract
|
||||
- PromptManagerText: Text-only node that outputs STRING with prepend/append
|
||||
functionality
|
||||
|
||||
Both nodes share the same database backend for persistent prompt storage and include
|
||||
The nodes share the same database backend for persistent prompt storage and include
|
||||
an automatic image gallery system that monitors ComfyUI output directories and links
|
||||
generated images to their source prompts.
|
||||
|
||||
@@ -37,24 +40,37 @@ def get_version():
|
||||
return "unknown"
|
||||
|
||||
|
||||
from .prompt_manager import PromptManager
|
||||
from .prompt_manager_text import PromptManagerText
|
||||
from .prompt_search_list import PromptSearchList
|
||||
from .prompt_manager import ( # noqa: E402
|
||||
PromptManager,
|
||||
PromptManagerFocused,
|
||||
PromptManagerSplit,
|
||||
)
|
||||
from .prompt_manager_pair import PromptManagerPair # noqa: E402
|
||||
from .prompt_manager_text import PromptManagerText # noqa: E402
|
||||
from .prompt_search_list import PromptSearchList # noqa: E402
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptManager": PromptManager,
|
||||
"PromptManagerFocused": PromptManagerFocused,
|
||||
"PromptManagerSplit": PromptManagerSplit,
|
||||
"PromptManagerPair": PromptManagerPair,
|
||||
"PromptManagerText": PromptManagerText,
|
||||
"PromptSearchList": PromptSearchList,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptManager": "Prompt Manager",
|
||||
"PromptManager": "Prompt Manager (Classic)",
|
||||
"PromptManagerFocused": "Prompt Manager (Focused)",
|
||||
"PromptManagerSplit": "Prompt Manager (Split)",
|
||||
"PromptManagerPair": "Prompt Manager (+/-)",
|
||||
"PromptManagerText": "Prompt Manager Text",
|
||||
"PromptSearchList": "Prompt Search List",
|
||||
}
|
||||
|
||||
# Define path to web directory for UI components
|
||||
WEB_DIRECTORY = "web"
|
||||
# Canvas extension only. ComfyUI loads every .js under this directory into the
|
||||
# canvas page, so the admin/gallery bundles and vendored libraries under web/
|
||||
# are served by our own routes instead (see py/api).
|
||||
WEB_DIRECTORY = "web/comfy"
|
||||
|
||||
# Add API routes (same pattern as ComfyUI_Assets)
|
||||
try:
|
||||
@@ -82,6 +98,10 @@ except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Shared database, set up with the monitoring system below. Stays None when that
|
||||
# fails so the queue hook can report the database as unavailable instead of crashing.
|
||||
_global_db = None
|
||||
|
||||
# Start image monitoring globally at module import time
|
||||
# This ensures images are linked regardless of which node is used in the workflow
|
||||
try:
|
||||
@@ -111,12 +131,41 @@ except Exception as e:
|
||||
except Exception:
|
||||
print(f"[ComfyUI-PromptManager] Warning: Failed to start image monitoring: {e}")
|
||||
|
||||
|
||||
def _db_factory():
|
||||
"""The shared database for the queue hook; called lazily per request."""
|
||||
if _global_db is None:
|
||||
raise RuntimeError("PromptManager database unavailable")
|
||||
return _global_db
|
||||
|
||||
|
||||
# Count prompt runs when ComfyUI dequeues workflows (it skips unchanged nodes, so
|
||||
# node execution alone misses re-runs). Separate from monitoring so either can fail.
|
||||
try:
|
||||
from .py.config import server_instance
|
||||
from .utils.usage_tracking import register_queue_hook
|
||||
|
||||
register_queue_hook(server_instance, _db_factory)
|
||||
except Exception as e:
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
|
||||
get_logger("prompt_manager.init").error(f"Failed to register usage hook: {e}")
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger("prompt_manager.init").error(
|
||||
f"Failed to register usage hook: {e}"
|
||||
)
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
# Print startup message with loaded tools
|
||||
print()
|
||||
print(f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}")
|
||||
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
|
||||
print(f"🫶 \033[94mLoaded:\033[0m {display_name}")
|
||||
# ASCII only: a Windows console using a legacy code page cannot print emoji,
|
||||
# and a UnicodeEncodeError here would abort loading the whole node.
|
||||
print(f" \033[94mLoaded:\033[0m {display_name}")
|
||||
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
|
||||
print()
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from .models import normalize_image_path
|
||||
from .operations import _coerce_file_info
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
state = resolution.get("state", "unresolved")
|
||||
if state != "resolved":
|
||||
return {"status": state, "pairs_added": 0, "links_added": 0}
|
||||
positive, negative = resolution["positive"], resolution["negative"]
|
||||
positive_text = positive.get("storage_text", positive["text"])
|
||||
negative_text = negative.get("storage_text", negative["text"])
|
||||
normalized = normalize_image_path(image_path)
|
||||
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
with db._pair_transaction() as conn:
|
||||
images = conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalized, image_path),
|
||||
).fetchall()
|
||||
if len(images) > 1 or (
|
||||
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
|
||||
):
|
||||
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
|
||||
existing = images[0] if images else None
|
||||
if existing:
|
||||
prior = conn.execute(
|
||||
"SELECT n.hash AS negative_hash FROM image_prompt_pairs ip "
|
||||
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE ip.image_id=?",
|
||||
(existing["id"],),
|
||||
).fetchall()
|
||||
expected = (
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None
|
||||
)
|
||||
if any(row["negative_hash"] != expected for row in prior):
|
||||
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
|
||||
prior_pair = conn.execute(
|
||||
"SELECT pp.id FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
||||
"WHERE p.hash=? AND n.hash IS ?",
|
||||
(
|
||||
generate_prompt_hash(positive_text),
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None,
|
||||
),
|
||||
).fetchone()
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
pair = conn.execute(
|
||||
"SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone()
|
||||
pairs_added = int(prior_pair is None)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
snapshots = {
|
||||
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
|
||||
for column, key in (
|
||||
("workflow_data", "workflow"),
|
||||
("prompt_metadata", "prompt"),
|
||||
("parameters", "parameters"),
|
||||
)
|
||||
if key in metadata
|
||||
}
|
||||
info = {
|
||||
"file_size": file_size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"format": image_format,
|
||||
}
|
||||
snapshots.update(
|
||||
{key: value for key, value in info.items() if value is not None}
|
||||
)
|
||||
changed = False
|
||||
if existing:
|
||||
image_id = existing["id"]
|
||||
updates = {
|
||||
key: value for key, value in snapshots.items() if existing[key] != value
|
||||
}
|
||||
if updates:
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET "
|
||||
+ ",".join(f"{key}=?" for key in updates)
|
||||
+ " WHERE id=?",
|
||||
[*updates.values(), image_id],
|
||||
)
|
||||
changed = True
|
||||
else:
|
||||
columns = ["prompt_id", "image_path", "filename", "file_path", *snapshots]
|
||||
values = [
|
||||
pair["positive_prompt_id"],
|
||||
image_path,
|
||||
os.path.basename(image_path),
|
||||
normalized,
|
||||
*snapshots.values(),
|
||||
]
|
||||
image_id = conn.execute(
|
||||
"INSERT INTO generated_images("
|
||||
+ ",".join(columns)
|
||||
+ ") VALUES("
|
||||
+ ",".join("?" for _ in columns)
|
||||
+ ")",
|
||||
values,
|
||||
).lastrowid
|
||||
linked = conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? "
|
||||
"AND image_id=? AND provenance=?",
|
||||
(pair_id, image_id, encoded),
|
||||
).fetchone()
|
||||
db._link_image_to_pair(conn, pair_id, image_id, provenance)
|
||||
status = (
|
||||
"added"
|
||||
if existing is None
|
||||
else "enriched" if changed or not linked else "unchanged"
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"pairs_added": pairs_added,
|
||||
"links_added": int(not linked),
|
||||
"pair_id": pair_id,
|
||||
"image_id": image_id,
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Ordered reusable prompt pairs and their generation provenance."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .models import pair_provenance_error, utc_now_iso
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class PairDependencyError(ValueError):
|
||||
"""An operation would change or remove content used by a saved pair."""
|
||||
|
||||
|
||||
def _validate_metadata(
|
||||
title: Optional[str],
|
||||
category: Optional[str],
|
||||
tags: Optional[List[str]],
|
||||
rating: Optional[int],
|
||||
notes: Optional[str],
|
||||
) -> Optional[List[str]]:
|
||||
for name, value in (("title", title), ("category", category), ("notes", notes)):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError(f"{name} must be a string")
|
||||
if rating is not None and (type(rating) is not int or not 1 <= rating <= 5):
|
||||
raise ValueError("Rating must be between 1 and 5")
|
||||
if tags is None:
|
||||
return None
|
||||
if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags):
|
||||
raise ValueError("tags must be a list of strings")
|
||||
return list(dict.fromkeys(tag.strip() for tag in tags if tag.strip()))
|
||||
|
||||
|
||||
class PairOperations:
|
||||
"""Connection-level pair operations mixed into PromptDatabase."""
|
||||
|
||||
@contextmanager
|
||||
def _pair_transaction(self) -> Iterator[sqlite3.Connection]:
|
||||
conn = self.model.get_connection()
|
||||
if conn.in_transaction:
|
||||
raise RuntimeError("Pair operations require their own transaction")
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
yield conn
|
||||
|
||||
def _ensure_prompts_unpaired(
|
||||
self, conn: sqlite3.Connection, prompt_ids: List[int]
|
||||
) -> None:
|
||||
if not prompt_ids:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in prompt_ids)
|
||||
row = conn.execute(
|
||||
f"SELECT id FROM prompt_pairs WHERE positive_prompt_id IN ({placeholders}) "
|
||||
f"OR negative_prompt_id IN ({placeholders}) LIMIT 1",
|
||||
[*prompt_ids, *prompt_ids],
|
||||
).fetchone()
|
||||
if row:
|
||||
raise PairDependencyError(
|
||||
f"Prompt belongs to saved pair {row['id']}; delete the pair first "
|
||||
"or save changed text as a new prompt"
|
||||
)
|
||||
|
||||
def _pair_component(self, conn: sqlite3.Connection, text: str) -> int:
|
||||
prompt_hash = generate_prompt_hash(text)
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(text, hash, created_at, updated_at) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(hash) DO NOTHING",
|
||||
(text.strip(), prompt_hash, utc_now_iso(), utc_now_iso()),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM prompts WHERE hash = ?", (prompt_hash,)
|
||||
).fetchone()["id"]
|
||||
|
||||
def _sync_pair_tags(
|
||||
self, conn: sqlite3.Connection, pair_id: int, tags: List[str]
|
||||
) -> None:
|
||||
conn.execute("DELETE FROM prompt_pair_tags WHERE pair_id = ?", (pair_id,))
|
||||
for tag_id in self._ensure_tags(conn, tags).values():
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pair_tags(pair_id,tag_id) VALUES(?,?)",
|
||||
(pair_id, tag_id),
|
||||
)
|
||||
|
||||
def save_prompt_pair(
|
||||
self,
|
||||
positive_text: str,
|
||||
negative_text: str = "",
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
with self._pair_transaction() as conn:
|
||||
return self._save_pair(
|
||||
conn,
|
||||
positive_text,
|
||||
negative_text,
|
||||
title=title,
|
||||
category=category,
|
||||
tags=tags,
|
||||
rating=rating,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def _save_pair(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
positive_text: str,
|
||||
negative_text: str = "",
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
if not isinstance(positive_text, str) or not positive_text.strip():
|
||||
raise ValueError("Positive prompt text cannot be empty")
|
||||
if not isinstance(negative_text, str):
|
||||
raise ValueError("Negative prompt text must be a string; use '' for empty")
|
||||
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||
positive = self._pair_component(conn, positive_text)
|
||||
negative = (
|
||||
self._pair_component(conn, negative_text) if negative_text.strip() else None
|
||||
)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_pairs WHERE positive_prompt_id = ? "
|
||||
"AND negative_prompt_id IS ?",
|
||||
(positive, negative),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
now = utc_now_iso()
|
||||
pair_id = conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id,title,"
|
||||
"category,rating,notes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
|
||||
(positive, negative, title, category, rating, notes, now, now),
|
||||
).lastrowid
|
||||
if tags:
|
||||
self._sync_pair_tags(conn, pair_id, tags)
|
||||
return pair_id
|
||||
|
||||
def _read_pair_rows(
|
||||
self, conn: sqlite3.Connection, where: str, params: List[Any], suffix: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"SELECT pp.*, p.text AS positive_text, "
|
||||
"COALESCE(n.text,'') AS negative_text,"
|
||||
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
|
||||
"WHERE ip.pair_id=pp.id) AS image_count FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where + suffix,
|
||||
params,
|
||||
).fetchall()
|
||||
pairs = [dict(row) for row in rows]
|
||||
if not pairs:
|
||||
return pairs
|
||||
by_id = {pair["id"]: pair for pair in pairs}
|
||||
for pair in pairs:
|
||||
pair["archived"] = bool(pair["archived"])
|
||||
pair["tags"] = []
|
||||
placeholders = ",".join("?" for _ in by_id)
|
||||
for row in conn.execute(
|
||||
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt JOIN tags t ON "
|
||||
f"t.id=pt.tag_id WHERE pt.pair_id IN ({placeholders}) ORDER BY t.name",
|
||||
list(by_id),
|
||||
):
|
||||
by_id[row["pair_id"]]["tags"].append(row["name"])
|
||||
return pairs
|
||||
|
||||
def get_prompt_pair(self, pair_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Return components, pair metadata and image count, including archives."""
|
||||
rows = self._read_pair_rows(self.model.get_connection(), "pp.id = ?", [pair_id])
|
||||
return rows[0] if rows else None
|
||||
|
||||
def list_prompt_pairs(
|
||||
self,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
include_archived: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
sort: str = "newest",
|
||||
) -> Dict[str, Any]:
|
||||
"""List pairs with all-tag filtering and stable paginated ordering."""
|
||||
orders = {
|
||||
"newest": "pp.id DESC",
|
||||
"last_used_desc": "julianday(pp.last_used_at) DESC, pp.id DESC",
|
||||
}
|
||||
if sort not in orders:
|
||||
raise ValueError("Unknown pair sort order")
|
||||
if type(limit) is not int or not 1 <= limit <= 500:
|
||||
raise ValueError("limit must be between 1 and 500")
|
||||
if type(offset) is not int or offset < 0:
|
||||
raise ValueError("offset must be a nonnegative integer")
|
||||
tags = _validate_metadata(None, category, tags, None, None)
|
||||
if text is not None and not isinstance(text, str):
|
||||
raise ValueError("text must be a string")
|
||||
clauses, params = ["1=1"], []
|
||||
if not include_archived:
|
||||
clauses.append("pp.archived=0")
|
||||
if category is not None:
|
||||
clauses.append("pp.category = ?")
|
||||
params.append(category)
|
||||
if text:
|
||||
clauses.append("(p.text LIKE ? OR n.text LIKE ? OR pp.title LIKE ?)")
|
||||
params.extend([f"%{text}%"] * 3)
|
||||
for tag in tags or []:
|
||||
clauses.append(
|
||||
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t "
|
||||
"ON t.id=pt.tag_id WHERE pt.pair_id=pp.id AND t.name=?)"
|
||||
)
|
||||
params.append(tag)
|
||||
where = " AND ".join(clauses)
|
||||
conn = self.model.get_connection()
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where,
|
||||
params,
|
||||
).fetchone()[0]
|
||||
pairs = self._read_pair_rows(
|
||||
conn,
|
||||
where,
|
||||
[*params, limit, offset],
|
||||
f" ORDER BY {orders[sort]} LIMIT ? OFFSET ?",
|
||||
)
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": offset + len(pairs) < total,
|
||||
}
|
||||
|
||||
def update_prompt_pair_metadata(
|
||||
self,
|
||||
pair_id: int,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Update pair metadata explicitly; None preserves and empty values clear."""
|
||||
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||
changes = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("title", title),
|
||||
("category", category),
|
||||
("rating", rating),
|
||||
("notes", notes),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
if not changes and tags is None:
|
||||
return False
|
||||
with self._pair_transaction() as conn:
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone():
|
||||
return False
|
||||
changes["updated_at"] = utc_now_iso()
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET "
|
||||
+ ",".join(f"{key}=?" for key in changes)
|
||||
+ " WHERE id=?",
|
||||
[*changes.values(), pair_id],
|
||||
)
|
||||
if tags is not None:
|
||||
self._sync_pair_tags(conn, pair_id, tags)
|
||||
return True
|
||||
|
||||
def archive_prompt_pair(self, pair_id: int, archived: bool = True) -> bool:
|
||||
"""Hide a reusable pair without changing components or generation history."""
|
||||
if type(archived) is not bool:
|
||||
raise ValueError("archived must be a boolean")
|
||||
with self._pair_transaction() as conn:
|
||||
return (
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET archived=?,updated_at=? WHERE id=?",
|
||||
(int(archived), utc_now_iso(), pair_id),
|
||||
).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def delete_prompt_pair(self, pair_id: int) -> bool:
|
||||
"""Delete unused pair metadata; generated pairs must be archived instead."""
|
||||
with self._pair_transaction() as conn:
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? LIMIT 1", (pair_id,)
|
||||
).fetchone():
|
||||
raise PairDependencyError(
|
||||
"Pair has generated images; archive it instead"
|
||||
)
|
||||
return (
|
||||
conn.execute("DELETE FROM prompt_pairs WHERE id=?", (pair_id,)).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def record_prompt_pair_use(self, pair_id: int, times: int = 1) -> bool:
|
||||
"""Count pair uses independently of standalone prompt usage."""
|
||||
if type(times) is not int or times < 1:
|
||||
raise ValueError("times must be a positive integer")
|
||||
with self._pair_transaction() as conn:
|
||||
return (
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET run_count=run_count+?,last_used_at=? "
|
||||
"WHERE id=?",
|
||||
(times, utc_now_iso(), pair_id),
|
||||
).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def link_image_to_pair(
|
||||
self, pair_id: int, image_id: int, provenance: Dict[str, Any]
|
||||
) -> int:
|
||||
"""Associate an image with immutable run provenance, idempotently."""
|
||||
with self._pair_transaction() as conn:
|
||||
return self._link_image_to_pair(conn, pair_id, image_id, provenance)
|
||||
|
||||
def _link_image_to_pair(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
pair_id: int,
|
||||
image_id: int,
|
||||
provenance: Dict[str, Any],
|
||||
) -> int:
|
||||
"""Associate an image with immutable run provenance, idempotently."""
|
||||
if not isinstance(provenance, dict):
|
||||
raise ValueError("provenance must be an object")
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
except (TypeError, ValueError, RecursionError) as error:
|
||||
raise ValueError("provenance must contain JSON values") from error
|
||||
error = pair_provenance_error(encoded)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
pair = conn.execute(
|
||||
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone()
|
||||
image = conn.execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE id=?", (image_id,)
|
||||
).fetchone()
|
||||
if not pair or not image:
|
||||
raise ValueError("Pair and image must exist")
|
||||
if pair["positive_prompt_id"] != image["prompt_id"]:
|
||||
raise ValueError("Image must belong to the pair's positive prompt")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
|
||||
"VALUES(?,?,?,?) ON CONFLICT(pair_id,image_id,provenance) DO NOTHING",
|
||||
(pair_id, image_id, encoded, utc_now_iso()),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM image_prompt_pairs "
|
||||
"WHERE pair_id=? AND image_id=? AND provenance=?",
|
||||
(pair_id, image_id, encoded),
|
||||
).fetchone()["id"]
|
||||
|
||||
def get_image_prompt_pairs(self, image_id: int) -> List[Dict[str, Any]]:
|
||||
"""Return every recorded pair association with decoded provenance."""
|
||||
rows = (
|
||||
self.model.get_connection()
|
||||
.execute(
|
||||
"SELECT * FROM image_prompt_pairs WHERE image_id=? ORDER BY id",
|
||||
(image_id,),
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
return [
|
||||
{**dict(row), "provenance": json.loads(row["provenance"])} for row in rows
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Application Redesign Development Plan
|
||||
|
||||
The maintainer approved the [interactive concept](concepts/workspace/README.md)
|
||||
on 2026-10-17. It is a design reference with simulated operations, not production
|
||||
code. This plan proposes implementation order and acceptance gates.
|
||||
|
||||
## Scope and compatibility
|
||||
|
||||
Rebuild the standalone application around one shared shell. Preserve existing
|
||||
node schemas, saved workflows, backend capabilities, pinned Viewer.js and the
|
||||
ComfyAssets toolkit. Keep existing application entry points usable during the
|
||||
transition. Do not change the running maintainer instance during automated tests.
|
||||
|
||||
Use the concept's feature inventory as a parity checklist. Each row needs an
|
||||
implementation issue, observable acceptance criteria and automated test coverage.
|
||||
Record any intentional behavior change explicitly; no silent feature removal.
|
||||
|
||||
## Proposed delivery sequence
|
||||
|
||||
Use a tracking issue and small issue-linked branches and PRs. Dependencies may
|
||||
require splitting these milestones further.
|
||||
|
||||
| Milestone | Deliverable and acceptance focus |
|
||||
| --- | --- |
|
||||
| 1. Foundation | Production shell, routing, shared theme and disposable page-test harness. Verify startup, navigation, dark/light changes, cleanup and existing entry points. Establish discovered-test and coverage baselines before changes. |
|
||||
| 2. Image workspace | Real API-backed filters, Grid/List/Masonry, readable thumbnails, selection and shared Viewer.js inspector. Continuous infinite scroll only, with accessible Load more fallback, stable ordering, retry and restored browsing context. Bound retained DOM and test large datasets. |
|
||||
| 3. Prompt library and history | Real positive/negative associations, selected full negative, run history and shared image filtering. Design any missing execution-history persistence and migration before wiring the timeline; never invent historical runs from incomplete metadata. |
|
||||
| 4. Send to ComfyUI | Shared paper-plane actions wherever prompt text appears; role-specific and pair sends, destination preview, acknowledgement and failure recovery. Cover explicit empty versus unknown negatives and connected inputs. Never queue or replace a workflow. |
|
||||
| 5. Editing and review | Prompt editing, vocabulary management, per-image manual/auto-tag approval and Needs review. Preserve cancellation semantics and distinguish tag approval from prompt-association decisions. |
|
||||
| 6. System and maintenance | Settings, sources/scans, integrations, jobs, diagnostics, backup/restore and maintenance impact previews. Cover existing capabilities before retiring their old controls. |
|
||||
| 7. Additional organization | Favorites independent of ratings, collections, saved views, Activity, scheduled backups and portable import previews. Specify persistence, validation and recovery separately for each capability. |
|
||||
| 8. Cutover | Complete the parity checklist, migration and upgrade validation, accessibility/performance checks, documentation and targeted maintainer live testing. Switch the default only after these pass; preserve compatible URLs. |
|
||||
|
||||
## TDD and test layers
|
||||
|
||||
For each behavior, first write and run a meaningful failing test. Implement the
|
||||
smallest working change, verify it passes, then refactor with the suite green.
|
||||
Include failing-before/passing-after evidence in the PR.
|
||||
|
||||
- **Unit:** filtering and selection state, cursor handling, prompt roles,
|
||||
formatting, validation, navigation and lifecycle cleanup.
|
||||
- **Integration:** real HTTP handlers and disposable SQLite/filesystem fixtures;
|
||||
persistence, migration from supported old schemas, idempotent scans, restore,
|
||||
rollback and reference-safe deletion. Mock inference and external services.
|
||||
- **Smoke:** shipped production pages start and navigate with real fixture APIs;
|
||||
test populated, empty and error states, assets and unexpected console failures.
|
||||
- **Browser workflows:** production HTML/JS across Chromium, Firefox, WebKit and
|
||||
touch; editing/reload, infinite append, viewer cleanup, tagging approval,
|
||||
keyboard access, delayed responses, cancellation and retries.
|
||||
- **Host integration:** pinned disposable CPU ComfyUI, Classic and Nodes 2.0,
|
||||
switching both ways and workflow reload for host-facing changes. Exercise
|
||||
independent and combined pack installations for shared bridge/toolkit changes.
|
||||
|
||||
Retain existing test discovery and coverage gates. Carry forward database
|
||||
injection and open-handle checks into new fixtures. Add new frontend modules to
|
||||
coverage measurement and its baseline; do not lower floors or replace existing
|
||||
regression coverage with mockup-only tests.
|
||||
|
||||
## GitHub and release gates
|
||||
|
||||
Extend existing required workflows: the Python 3.10–3.13 matrix on Linux,
|
||||
Windows and macOS; 85% Python coverage; formatting; frontend units and per-file
|
||||
coverage floors; vendor checks; browser workflows; real ComfyUI compatibility.
|
||||
Publish traces, screenshots, logs and coverage artifacts on failures. See
|
||||
[TESTING.md](../TESTING.md) for commands and fixture isolation requirements.
|
||||
|
||||
Every PR must map acceptance criteria to tests, report relevant checks and include
|
||||
screenshots for visual changes. All required checks must pass before merging.
|
||||
Use maintainer live review at meaningful visual and host-integration milestones;
|
||||
capture discovered regressions in automated tests. Notify the maintainer when
|
||||
Python changes require a ComfyUI restart.
|
||||
|
||||
Keep version/publication changes separate from intermediate UI work: the existing
|
||||
registry workflow publishes on main/master changes to `pyproject.toml` or manual
|
||||
dispatch. Do not trigger a release merely to preview a redesign milestone.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Prompt Manager application redesign concept
|
||||
|
||||
Status: visual concept approved by the maintainer, 2026-10-17. Production
|
||||
implementation is pending; see the [development plan](../../application-redesign.md).
|
||||
|
||||
## Review the mockup
|
||||
|
||||
Run `python tools/preview-prompt-timeline.py --bind 0.0.0.0 --port 8767` and
|
||||
open `/workspace/`. The original approved timeline remains available at `/`.
|
||||
The server exposes an explicit allowlist of concept files and pinned assets.
|
||||
All examples are synthetic; there are no application API calls, database access,
|
||||
real model downloads, or ComfyUI handoffs. Local file inspection previews an
|
||||
image in the browser without uploading it. Exports contain sample data only.
|
||||
|
||||
The sidebar opens Images, Prompt library, Tags & categories, Inspect a file,
|
||||
Activity, System and Design review. System includes Overview, Appearance,
|
||||
Sources & scans, Integrations, Auto-tagging, Maintenance, Backup & export, and
|
||||
Diagnostics & logs. The in-app Design review contains the feature map below.
|
||||
|
||||
## Proposed direction
|
||||
|
||||
One application shell replaces separate Admin, Gallery and Metadata shells.
|
||||
Use the approved shared ComfyAssets theme: ComfyUI dark/light, blue primary
|
||||
actions, green positive roles, red negative roles and errors. Preserve existing
|
||||
capabilities and services while consolidating controls and clarifying their scope.
|
||||
|
||||
Browsing is image-led with readable thumbnails and one metadata inspector.
|
||||
Prompt library groups by positive; negative variants and runs share its gallery.
|
||||
The full selected negative appears directly beneath the positive in history.
|
||||
Favorites express preference independently of the existing quality rating.
|
||||
Collections organize projects without duplicating recipe text; saved views keep
|
||||
search/filter context. New capabilities are proposals, not implemented features.
|
||||
|
||||
Maintenance moves out of the browsing toolbar. Checks, impact preview, backups,
|
||||
and apply steps have separate responsibilities. Distinguish prompt text reuse
|
||||
from image-file duplicates, and metadata enrichment from thumbnail generation.
|
||||
No repeated global thumbnail-generation prompt on opening the gallery.
|
||||
|
||||
## Send to ComfyUI
|
||||
|
||||
Use the same original paper-plane outline, blue accent and accessible role label
|
||||
on every positive/negative prompt block, including image details and the viewer.
|
||||
A pair action sends both. Preview destination and exact fields before applying;
|
||||
positive-only and negative-only actions leave the other role untouched. An
|
||||
explicitly empty negative means clear the target field; unknown metadata must
|
||||
never be interpreted as empty. Sending text neither queues execution nor
|
||||
replaces a workflow.
|
||||
|
||||
Production needs a shared host bridge with versioned payloads, origin validation,
|
||||
destination discovery, capability checks, disconnected-host handling, linked-input
|
||||
checks and acknowledgements. Preview actual target values and report success only
|
||||
after acknowledgement. Default to asking for a destination; never guess between
|
||||
multiple nodes or positive and negative encoder inputs. Verify Classic and Nodes
|
||||
2.0 with both packs installed independently and together.
|
||||
|
||||
## Existing feature inventory
|
||||
|
||||
| Current feature | Proposed home / behavior | Disposition |
|
||||
| --- | --- | --- |
|
||||
| Admin prompt cards / per-prompt gallery | Prompt library + one prompt history workspace | Retained, rebuilt |
|
||||
| Standalone image gallery / Admin image modal | One Images workspace + shared Viewer.js viewer | Consolidated |
|
||||
| Video playback, autoplay / mute / loop, viewer slideshow and transforms | Shared media viewer; playback controls in Appearance; same image navigation | Retained |
|
||||
| Metadata page / viewer sidebar / full-text popups | One reusable metadata inspector; inline complete prompt text | Consolidated |
|
||||
| Prompt add, edit, rating, notes, copy, delete | Recipe editor, shared details, contextual bulk actions; reference-safe archive | Retained, clarified |
|
||||
| Categories, tag rename / merge, autocomplete | Tags & categories; same vocabulary in every editor | Retained |
|
||||
| Grid/list, thumbnail size, columns, pages/infinite scroll | Infinite scroll only; one Appearance configuration and contextual layout / density overrides | Simplified |
|
||||
| Scan, rescan, monitoring directories, worker threads | Sources & scans; explicit metadata enrichment vs thumbnail work | Consolidated |
|
||||
| Ollama, HuggingFace, WD14 variants, thresholds, review | One Auto-tagging configuration and review flow | Retained |
|
||||
| Thumbnail quality, size limit, metadata cache, paths | Appearance + cache controls in Maintenance | Retained |
|
||||
| Prompt dedup, hash checks, consistency, unused prompts | Maintenance plan with dependency checks and impact preview | Retained, safer flow |
|
||||
| Image dedup, orphan image links, browser cache | Separate reviewed operations in Maintenance | Retained |
|
||||
| Database backup / restore, JSON export | Backup & export; recovery and portable recipes clearly distinguished | Retained |
|
||||
| Diagnostics, image-link checks, logs, rotation, log levels | System → Diagnostics & logs | Retained |
|
||||
| LoRA detection/import, trigger injection, CivitAI key | System → Integrations; positive-only triggers | Retained |
|
||||
| Statistics / counts | System overview + usage breakdown; context counts in the workspace | Retained |
|
||||
| Send prompts to ComfyUI from every prompt surface | Role-aware paper-plane action + destination / overwrite preview | New proposal |
|
||||
| Reusable negatives / approved branch timeline | Prompt history with selected negative directly beneath positive | Approved concept |
|
||||
| Favorites, saved searches, project collections | Sidebar views; collections do not duplicate recipe text | New proposal |
|
||||
| Ambiguous metadata review, source provenance, run comparisons | Needs review queue; recorded vs inferred evidence; pair/run selection | New / expanded |
|
||||
| Unified jobs, cancellation, resumable progress visibility | Activity center; cancellation distinguished from hiding progress | New proposal |
|
||||
| Scheduled backups, pre-change safety snapshots | Explicit recovery policy after storage configuration | New proposal |
|
||||
|
||||
## Implementation boundaries and test requirements
|
||||
|
||||
Keep Viewer.js 1.15.2 and the shared toolkit as reproducible pinned artifacts;
|
||||
this mock uses their actual checked-in files without modifying them. Preserve
|
||||
legacy routes during the transition, current node layouts/schemas, existing
|
||||
controllers where reusable, and all existing test/coverage gates.
|
||||
|
||||
The timeline needs persisted execution records to represent real runs. Saved
|
||||
views, collections, metadata review resolution, activity history, scheduled
|
||||
backups, portable JSON import, and the ComfyUI bridge need separate implementation
|
||||
and persistence work. Preserve source evidence and distinguish recorded from
|
||||
inferred metadata. Existing schema migration is automatic; old image enrichment
|
||||
remains an optional explicit scan. The one-time upgrade notice belongs in the
|
||||
new shell, with Scan and Later choices.
|
||||
|
||||
Implement in reviewed slices: shared application shell and inspector; reusable
|
||||
prompt blocks/send bridge; image workspace; grouped library/history; settings and
|
||||
maintenance; optional organization and workflow improvements. Write regressions
|
||||
first for production behavior. Retain Python, frontend unit, Chromium/Firefox/
|
||||
WebKit/touch and managed Classic/Nodes 2.0 tests. Add behavior tests for shared
|
||||
filters, navigation state, viewer cleanup, interrupted jobs, dependency-safe
|
||||
maintenance, backup/restore, paired metadata and correct send destinations.
|
||||
|
||||
Screenshots in `screenshots/` record the reviewed starting point. The approved
|
||||
standalone timeline in `../prompt-variant-timeline.html` remains unchanged.
|
||||
|
||||
## Review polish
|
||||
|
||||
History actions use a shared branch icon. Images can switch among Grid, List and
|
||||
Masonry; Masonry preserves each image's proportions and keeps DOM order while
|
||||
packing cards with a responsive layout. It does not require experimental native
|
||||
masonry support. Resize observers are disconnected when leaving the view.
|
||||
|
||||
Every image card includes a tagging action beside Info, including cards in Needs
|
||||
review. Choose WD14 SwinV2, WD14 ViT, Ollama or HuggingFace; adjust thresholds or
|
||||
instructions, generate sample suggestions, edit tags manually, and approve only
|
||||
the desired suggestions. Changes are staged until approval, apply to that image
|
||||
alone and do not resolve uncertain prompt associations. Changing models discards
|
||||
previous suggestions. Cancel preserves existing tags. All inference is simulated.
|
||||
|
||||
## Continuous browsing decision
|
||||
|
||||
The redesign uses infinite scroll only. Remove page numbers, previous/next page
|
||||
buttons, paged/infinite mode selection and results-per-page controls. Keep request
|
||||
batching internal. The mock appends 12 images at a time in Grid, List and Masonry;
|
||||
it preserves existing card nodes, focus and selection while appending. It also
|
||||
retains the loaded range when switching layouts or revisiting a search. A
|
||||
keyboard-accessible Load more action provides an alternate trigger for the same
|
||||
continuous stream, not pagination. Exhausted and empty results are explicit.
|
||||
|
||||
Production will need bounded rendering, stable cursors, scroll restoration and
|
||||
loading/error/retry behavior for large collections. Existing backend pagination
|
||||
can continue providing internal request batches; no production tests or routes
|
||||
are removed as part of this prototype.
|
||||
@@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Images · Prompt Manager concept</title>
|
||||
<link rel="stylesheet" href="/toolkit/controls.css" />
|
||||
<link rel="stylesheet" href="/vendor/viewer.css" />
|
||||
<link rel="stylesheet" href="/workspace/style.css" />
|
||||
<script src="/vendor/viewer.js" defer></script>
|
||||
<script src="/workspace/app.js" type="module"></script>
|
||||
</head>
|
||||
<body class="ca-tools">
|
||||
<a class="skip" href="#main">Skip to workspace</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#images"
|
||||
><span class="brand-mark">P<span>±</span></span
|
||||
><span>Prompt Manager<small>COMFYASSETS</small></span></a
|
||||
>
|
||||
<div class="sidebar-label">WORKSPACE</div>
|
||||
<nav id="primary-nav" aria-label="Workspace"></nav>
|
||||
<div class="sidebar-label">
|
||||
SAVED VIEWS <span class="new-label">PROPOSED</span>
|
||||
</div>
|
||||
<nav id="saved-nav" aria-label="Saved views"></nav>
|
||||
<div class="sidebar-label">
|
||||
COLLECTIONS <span class="new-label">PROPOSED</span>
|
||||
</div>
|
||||
<nav id="collections-nav" aria-label="Collections"></nav>
|
||||
<div class="sidebar-bottom">
|
||||
<nav id="system-nav" aria-label="Tools and system"></nav>
|
||||
<div class="connection">
|
||||
<i></i>
|
||||
<div>
|
||||
ComfyUI workspace<small
|
||||
>Demo connection · Classic / Nodes 2.0</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumbs">
|
||||
ComfyAssets <span>/</span> <span id="breadcrumb">Images</span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span class="concept-badge">Interactive concept</span
|
||||
><button id="activity-open" type="button">
|
||||
Activity <span class="count" id="activity-count">2</span></button
|
||||
><label class="theme-label" for="theme"
|
||||
>Theme
|
||||
<select id="theme" aria-label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system">System</option>
|
||||
</select></label
|
||||
><a class="canvas-link" href="#send"
|
||||
>ComfyUI <span aria-hidden="true">↗</span></a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" tabindex="-1"></main>
|
||||
<footer class="app-footer">
|
||||
<span
|
||||
>Design prototype · sample images and data · actions stay in this
|
||||
tab</span
|
||||
><button type="button" id="review-link">What changes & why</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast" role="status" aria-atomic="true"></div>
|
||||
<dialog id="modal" aria-labelledby="modal-title">
|
||||
<div class="dialog-header">
|
||||
<h2 id="modal-title"></h2>
|
||||
<button type="button" id="modal-close" aria-label="Close dialog">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div id="modal-body"></div>
|
||||
</dialog>
|
||||
<dialog id="viewer-dialog" aria-labelledby="viewer-title">
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<h2 id="viewer-title">Image details</h2>
|
||||
<p class="muted small">
|
||||
One viewer · image, prompts, generation data, workflow
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" id="viewer-close" aria-label="Close image viewer">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="viewer-layout">
|
||||
<div class="viewer-left">
|
||||
<div id="viewer-stage"></div>
|
||||
<div class="viewer-toolbar" id="viewer-toolbar"></div>
|
||||
</div>
|
||||
<aside id="viewer-details" aria-label="Image metadata"></aside>
|
||||
</div>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,111 @@
|
||||
# Reusable positive and negative prompts
|
||||
|
||||
Tracking: [#193](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/193).
|
||||
This work builds on the merged Split node in PR #192. Keep the feature unmerged
|
||||
until maintainer live testing is complete.
|
||||
|
||||
## Storage contract
|
||||
|
||||
Keep existing prompt IDs, text hashes, metadata, image links and node schemas.
|
||||
A component can serve either role. Ordered combinations refer to shared prompt
|
||||
rows; an explicitly empty negative uses a null reference. Missing or ambiguous
|
||||
metadata does not mean an empty negative. Pair metadata belongs to the
|
||||
combination; saving another combination does not overwrite component metadata.
|
||||
Existing trim/lowercase hash normalization remains unchanged.
|
||||
|
||||
Saved components are immutable while referenced. To change text, create or reuse
|
||||
a component and save a new combination. Archive combinations that have generation
|
||||
history. Pruning and legacy editing/deletion must respect these dependencies.
|
||||
Historical workflow snapshots remain intact, including their original text.
|
||||
|
||||
## Migration and recovery
|
||||
|
||||
Create pair tables after existing legacy rebuild migrations, in an atomic,
|
||||
repeatable migration. Never scan media during schema initialization. Test fresh,
|
||||
legacy and current databases, repeated startup, injected failures and retries.
|
||||
Preserve old backup compatibility. Migrate and validate a disposable backup copy
|
||||
before replacing live database pages. Reject broken pair schemas, foreign keys,
|
||||
triggers and views. Use SQLite backup APIs so committed WAL data is included.
|
||||
|
||||
## Metadata and scanning
|
||||
|
||||
Resolve positive and negative from the same sampler/output context. Track both
|
||||
node ID and output slot; bound graph traversal and text construction. Unknown
|
||||
conditioning operations and conflicting sampler candidates remain unresolved.
|
||||
Never guess that the first encoder or most recent negative belongs to an image.
|
||||
|
||||
Import high-confidence pairs atomically with their image association and source
|
||||
provenance. Repeated scans must not duplicate components, pairs or image links,
|
||||
regenerate thumbnails, or increment generation run counts. Preserve existing
|
||||
conflicting/manual associations for review. Report new, enriched, unchanged,
|
||||
ambiguous, unresolved and failed results separately.
|
||||
|
||||
## Live node
|
||||
|
||||
Use the shared toolkit and Split foundation. Positive and negative editors have
|
||||
equal size. Only positive receives prepend, append and automatic LoRA triggers.
|
||||
Keep upstream connections authoritative. Library selection previews both fields
|
||||
before an explicit load. Saving text changes creates another reusable combination.
|
||||
Verify Classic and Nodes 2.0 startup, switching both directions, reload, caching,
|
||||
linked inputs, popup behavior and lifecycle cleanup before maintainer testing.
|
||||
|
||||
## Delivery gate
|
||||
|
||||
Keep all existing coverage floors and test suites. Record regression failures
|
||||
before implementing each behavior. Tests use disposable state and the managed
|
||||
CPU host; never mutate the maintainer's running ComfyUI or personal database.
|
||||
Python changes require a ComfyUI restart. Live approval is required before merge.
|
||||
|
||||
## Live testing
|
||||
|
||||
1. Download a database backup from Admin before the first restart. The additive
|
||||
schema migration runs on startup; existing workflows and prompt IDs stay valid.
|
||||
2. Restart ComfyUI, reload the browser, and add **Prompt Manager (+/-)** from
|
||||
**ComfyAssets / Prompts**. Connect CLIP and the two conditioning outputs to the
|
||||
sampler's positive and negative inputs.
|
||||
3. Enter both texts, save a pair, then keep positive unchanged and change negative.
|
||||
Recent should show both combinations with distinct negative excerpts. Selecting
|
||||
one previews both; **Use pair** loads both together.
|
||||
4. Test category/tag autocomplete and metadata opt-in, resizing, light/dark,
|
||||
switching Classic / Nodes 2.0 both ways, and workflow reload. Connected fields
|
||||
stay authoritative; run the node to save connected metadata values.
|
||||
5. Queue the same recipe twice and change only negative once. Usage should count
|
||||
dequeued jobs even when ComfyUI caches the node. Prepend, append and LoRA triggers
|
||||
affect positive only. Gallery text is labelled as recorded during execution or
|
||||
recovered from workflow metadata.
|
||||
6. Run an output metadata scan to enrich existing images. Repeat it: pairs and
|
||||
links must remain deduplicated, with unchanged results rather than new links.
|
||||
Conflicting or ambiguous files need review and are preserved.
|
||||
|
||||
Fresh executions embed bounded text evidence in PNG metadata. A cached node may
|
||||
not emit fresh evidence, so its graph-derived text is explicitly marked inferred.
|
||||
Multiple distinct batch records without an image anchor remain unresolved.
|
||||
Rescanning after interruption is safe through idempotent per-file writes; this
|
||||
version does not persist a filesystem scan cursor across server restarts.
|
||||
The API can request cancellation with `POST /prompt_manager/scan/cancel`; the
|
||||
existing dashboard Hide button continues to hide progress without cancelling.
|
||||
|
||||
Pair APIs are under `/prompt_manager/pairs`: POST saves a combination, GET lists
|
||||
and filters, GET/PATCH/DELETE `/{id}` reads, edits metadata or removes an unused
|
||||
pair. Changing text creates another combination. Null metadata updates preserve
|
||||
existing values; empty strings/lists clear supported fields. Archive a pair with
|
||||
`PATCH {"archived":true}`. GET `/export` provides versioned JSON containing both
|
||||
texts and pair metadata; this is an export format, not a JSON import endpoint.
|
||||
|
||||
## Gallery redesign concept retained
|
||||
|
||||
The maintainer approved the interactive
|
||||
[negative variant timeline concept](concepts/prompt-variant-timeline.html) on
|
||||
2026-10-17. Preserve it for the later gallery redesign; it is not production UI
|
||||
and does not expand the current node work.
|
||||
|
||||
Keep one positive parent and one shared gallery, with red branches for reusable
|
||||
negative variants and smaller dots for repeat runs. Show the selected negative
|
||||
in full directly beneath the unchanged positive; selecting another variant or
|
||||
run updates that text and filters the shared gallery. Comparison shows both
|
||||
negatives. Distinguish explicitly empty negatives from an all-variants view and
|
||||
saved variants from executed runs. Production history needs persisted execution
|
||||
records; the prototype's timeline uses illustrative sample data.
|
||||
|
||||
The Admin upgrade/scan notice remains a separate follow-up: explain automatic
|
||||
schema migration and offer an optional metadata enrichment scan or Later.
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,71 @@
|
||||
# Shared ComfyAssets UI adoption
|
||||
|
||||
Status: approved direction; toolkit/testing foundation in progress.
|
||||
Product UI brainstorming comes before node changes.
|
||||
|
||||
Tracking: [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186)
|
||||
and [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81).
|
||||
|
||||
KikoTools is the validated model. The
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) is the
|
||||
shared source; PromptManager proves the package boundaries before a later
|
||||
KikoTools adoption PR. Other custom nodes can become additional consumers.
|
||||
|
||||
## Approved sequence
|
||||
|
||||
1. Extract reusable controls and test infrastructure from the working KikoTools
|
||||
reference into the toolkit. Keep routes, node schemas and controllers local.
|
||||
2. Bring PromptManager's automated tests up to parity using the pinned toolkit.
|
||||
3. Brainstorm PromptManager node layouts, controls and behavior with the maintainer
|
||||
before changing its production UI. Select one bounded first consumer together.
|
||||
4. Validate shared distribution, themes, cleanup and compatibility through that
|
||||
consumer, then migrate incrementally. Test both packs together as appropriate.
|
||||
5. Backport toolkit adoption to KikoTools after PromptManager proves it, preserving
|
||||
the existing KikoTools appearance, behavior and workflow compatibility.
|
||||
|
||||
KikoTools is not being migrated again during this phase. The original
|
||||
[foundation mockup](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
|
||||
records the approved visual direction and inspiration.
|
||||
|
||||
## PromptManager requirements
|
||||
|
||||
- Preserve existing node names, inputs/outputs, API routes, saved workflows,
|
||||
gallery behavior, and existing test/coverage gates.
|
||||
- Use shared control factories, editor slots, settings/help/window behavior and
|
||||
the ComfyUI adapter where applicable. Keep PromptManager controllers and HTTP
|
||||
behavior in this repository.
|
||||
- Both projects must look identical: one shared set of controls, typography,
|
||||
spacing, colors and interaction states. Follow ComfyUI light/dark mode with
|
||||
our own styling, a blue primary accent and strong red errors/destructive actions.
|
||||
Use compact buttons, pill switches, segmented selections and responsive rows.
|
||||
Review the pilot mockup before production styling. Existing `--pm-*` tokens
|
||||
are migration context, not an approved design to retain.
|
||||
- Theme standalone pages explicitly: `window.open` pages do not inherit the
|
||||
host's CSS variables. Test the agreed palette policy, live changes, fallbacks
|
||||
and persistence, including errors when host settings are unavailable.
|
||||
- Ship a pinned, reproducible browser distribution. Installation must work
|
||||
without npm or KikoTools, including when both packs ship different core versions.
|
||||
- Attribute [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma) as architectural
|
||||
inspiration. Do not copy/vendor his code, styles, or assets.
|
||||
|
||||
## First migration
|
||||
|
||||
With the maintainer, inventory the PromptManager nodes and gallery dialogs and
|
||||
choose one bounded first UI consumer after brainstorming. Record its current behavior with tests before changing it.
|
||||
Verify keyboard opening/closing, focus restoration, accessible labels, confirm
|
||||
and cancel behavior, network failures, and light/dark appearances. Keep the
|
||||
existing gallery and destructive-action browser workflows passing.
|
||||
|
||||
Build shared-source extraction and distribution checks before importing the core.
|
||||
Test both packs alone and together, scoped styling, isolated settings/storage,
|
||||
and independent cleanup. ComfyUI-facing changes require the real-host Classic,
|
||||
Nodes 2.0 and live-switching matrix from the foundation proposal. Standalone
|
||||
dialogs additionally run through PromptManager's existing browser harness.
|
||||
|
||||
## Delivery
|
||||
|
||||
Use small issue-linked PRs and TDD: verify each new behavioral test fails before
|
||||
implementing it, then run relevant suites and required CI checks. Include visual
|
||||
evidence for appearance changes. Migrate additional nodes/dialogs/editors only after
|
||||
the first consumer validates the package boundaries; do not rewrite all pages
|
||||
or change backend schemas as part of this initiative.
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -4,7 +4,7 @@ with persistent prompt storage and search capabilities.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
|
||||
@@ -51,7 +51,9 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
{
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
"tooltip": "The text prompt to be encoded and saved to database.",
|
||||
"tooltip": (
|
||||
"The text prompt to be encoded and saved to database."
|
||||
),
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
@@ -64,49 +66,73 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Optional category for organizing prompts (e.g., 'landscapes', 'portraits')",
|
||||
"tooltip": (
|
||||
"Optional category for organizing prompts "
|
||||
"(e.g., 'landscapes', 'portraits')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"tags": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated tags for the prompt (e.g., 'anime, detailed, sunset')",
|
||||
"tooltip": (
|
||||
"Comma-separated tags for the prompt "
|
||||
"(e.g., 'anime, detailed, sunset')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"search_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Search for past prompts containing this text",
|
||||
"tooltip": (
|
||||
"Search past prompts by text; add tag:name "
|
||||
'(or tag:"two words") to filter by tag'
|
||||
),
|
||||
},
|
||||
),
|
||||
"prepend_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"tooltip": "Text to prepend to the main prompt (connected STRING nodes will be added before the main text)"
|
||||
"tooltip": (
|
||||
"Text to prepend to the main prompt (connected STRING "
|
||||
"nodes will be added before the main text)"
|
||||
)
|
||||
},
|
||||
),
|
||||
"append_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"tooltip": "Text to append to the main prompt (connected STRING nodes will be added after the main text)"
|
||||
"tooltip": (
|
||||
"Text to append to the main prompt (connected STRING "
|
||||
"nodes will be added after the main text)"
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
# prompt of a run from negative-prompt PromptManager nodes
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.CONDITIONING, IO.STRING)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"A conditioning containing the embedded text used to guide the diffusion model.",
|
||||
"The final combined text string (with prepend/append applied) that was encoded.",
|
||||
"A conditioning containing the embedded text used to guide the diffusion "
|
||||
"model.",
|
||||
"The final combined text string (with prepend/append applied) that was "
|
||||
"encoded.",
|
||||
)
|
||||
FUNCTION = "encode_prompt"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used to guide "
|
||||
"the diffusion model towards generating specific images. Additionally saves all prompts "
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images. "
|
||||
"Additionally saves all prompts "
|
||||
"to a local SQLite database with optional metadata for search and retrieval."
|
||||
)
|
||||
|
||||
@@ -119,6 +145,8 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
) -> Tuple[Any]:
|
||||
"""
|
||||
Encode the text prompt and save it to the database.
|
||||
@@ -133,7 +161,8 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
append_text: Text to append to the main prompt
|
||||
|
||||
Returns:
|
||||
Tuple containing the conditioning for the diffusion model and the final text string
|
||||
Tuple containing the conditioning for the diffusion model and the final
|
||||
text string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If clip input is invalid
|
||||
@@ -148,10 +177,14 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
parts.append(append_text.strip())
|
||||
final_text = " ".join(parts)
|
||||
|
||||
# Inject LoRA trigger words if integration is enabled
|
||||
final_text = self._inject_lora_trigger_words(final_text)
|
||||
|
||||
# Use the combined text for encoding
|
||||
encoding_text = final_text
|
||||
|
||||
# For database storage, save the original main text with metadata about prepend/append
|
||||
# For database storage, save the original main text with metadata
|
||||
# about prepend/append
|
||||
storage_text = text
|
||||
|
||||
# Validate CLIP model
|
||||
@@ -178,30 +211,24 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
extended_tags.append(f"append:{append_text.strip()[:50]}")
|
||||
|
||||
try:
|
||||
prompt_id = self._save_prompt_to_database(
|
||||
prompt_id = self._track_prompt_execution(
|
||||
text=storage_text.strip(),
|
||||
encoding_text=encoding_text.strip(),
|
||||
category=category.strip() if category else None,
|
||||
tags=extended_tags if extended_tags else None,
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
"final_text": encoding_text.strip(),
|
||||
},
|
||||
prompt_graph=prompt,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
# Set current prompt for image tracking
|
||||
if prompt_id:
|
||||
execution_id = self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=encoding_text.strip(),
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
"final_text": encoding_text.strip(),
|
||||
},
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Set execution context: {execution_id} for prompt ID: {prompt_id}"
|
||||
)
|
||||
self.logger.debug(f"Tracked prompt ID: {prompt_id}")
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the encoding
|
||||
@@ -246,10 +273,30 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
"""
|
||||
ComfyUI method to determine if node needs re-execution.
|
||||
|
||||
Returns a hash of input values that affect the conditioning output.
|
||||
This enables proper branch execution - only re-execute when inputs change.
|
||||
Returns a hash of input values that affect the conditioning output or
|
||||
the saved prompt's metadata. This enables proper branch execution -
|
||||
only re-execute when inputs change.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
combined = f"{text}|{prepend_text}|{append_text}"
|
||||
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()
|
||||
|
||||
|
||||
class PromptManagerFocused(PromptManager):
|
||||
"""The editor-first UI uses the existing encoding and storage contract."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
inputs = super().INPUT_TYPES()
|
||||
for name in ("prepend_text", "append_text"):
|
||||
kind, options = inputs["optional"][name]
|
||||
inputs["optional"][name] = (
|
||||
kind,
|
||||
{**options, "default": "", "forceInput": True},
|
||||
)
|
||||
return inputs
|
||||
|
||||
|
||||
class PromptManagerSplit(PromptManagerFocused):
|
||||
"""The persistent library layout shares Focused encoding and storage."""
|
||||
|
||||
@@ -6,9 +6,7 @@ search, gallery system management, and cleanup — extracted from the
|
||||
duplicate code in prompt_manager.py and prompt_manager_text.py.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import webbrowser
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
@@ -24,6 +22,9 @@ try:
|
||||
from .utils.comfyui_integration import get_comfyui_integration
|
||||
from .utils.image_monitor import get_image_monitor
|
||||
from .utils.prompt_tracker import get_prompt_tracker
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from .utils.usage_tracking import PENDING_FIRST_USE
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
@@ -32,6 +33,9 @@ except ImportError:
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
from utils.image_monitor import get_image_monitor
|
||||
from utils.prompt_tracker import get_prompt_tracker
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from utils.usage_tracking import PENDING_FIRST_USE
|
||||
|
||||
|
||||
class PromptManagerBase:
|
||||
@@ -56,14 +60,23 @@ class PromptManagerBase:
|
||||
self.logger.debug(f"{self.__class__.__name__} node initialization completed")
|
||||
|
||||
def _save_prompt_to_database(
|
||||
self, text: str, category: Optional[str] = None, tags: Optional[list] = None
|
||||
self,
|
||||
text: str,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[list] = None,
|
||||
count_run: bool = False,
|
||||
) -> Optional[int]:
|
||||
"""Save the prompt to the SQLite database.
|
||||
|
||||
Runs are normally counted by the queue hook (utils/usage_tracking.py), so a
|
||||
save only counts one when asked to (batch items whose text the hook could not
|
||||
see) or when the hook queued this prompt before it existed.
|
||||
|
||||
Args:
|
||||
text: The prompt text
|
||||
category: Optional category
|
||||
tags: List of tags
|
||||
count_run: Count this execution as a run of the prompt
|
||||
|
||||
Returns:
|
||||
The prompt ID if saved successfully, None otherwise
|
||||
@@ -82,6 +95,8 @@ class PromptManagerBase:
|
||||
prompt_id=existing["id"], category=category, tags=tags
|
||||
)
|
||||
self.logger.debug("Updated metadata for existing prompt")
|
||||
if count_run:
|
||||
self._record_use(existing["id"])
|
||||
return existing["id"]
|
||||
|
||||
self.logger.debug(
|
||||
@@ -93,6 +108,10 @@ class PromptManagerBase:
|
||||
|
||||
if prompt_id:
|
||||
self.logger.debug(f"Successfully saved new prompt with ID: {prompt_id}")
|
||||
pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
|
||||
runs = pending.consume(prompt_hash) + (1 if count_run else 0)
|
||||
if runs:
|
||||
self._record_use(prompt_id, times=runs)
|
||||
else:
|
||||
self.logger.warning("Failed to save prompt - no ID returned")
|
||||
|
||||
@@ -102,17 +121,110 @@ class PromptManagerBase:
|
||||
self.logger.error(f"Error saving prompt to database: {e}")
|
||||
return None
|
||||
|
||||
def _generate_hash(self, text: str) -> str:
|
||||
"""Generate SHA256 hash for the prompt text.
|
||||
def _prompt_role(
|
||||
self, prompt_graph: Any, unique_id: Any, text: str
|
||||
) -> Dict[str, bool]:
|
||||
"""How this node's execution relates to the run, from the queued graph.
|
||||
|
||||
Args:
|
||||
text: The prompt text to hash
|
||||
|
||||
Returns:
|
||||
Hexadecimal string representation of the SHA256 hash
|
||||
Returns flags:
|
||||
is_run_prompt: feeds a sampler's positive input (images belong to it)
|
||||
seen_at_queue: the queue hook resolved exactly this text, so it already
|
||||
counted the run and the image's metadata can identify the prompt
|
||||
known: the graph and node id were available
|
||||
"""
|
||||
normalized_text = text.strip().lower()
|
||||
return hashlib.sha256(normalized_text.encode("utf-8")).hexdigest()
|
||||
if not isinstance(prompt_graph, dict) or unique_id is None:
|
||||
return {"is_run_prompt": True, "seen_at_queue": False, "known": False}
|
||||
node_id = str(unique_id)
|
||||
resolved = resolve_text(prompt_graph, node_id)
|
||||
return {
|
||||
"is_run_prompt": node_id in run_prompt_nodes(prompt_graph),
|
||||
"seen_at_queue": resolved is not None
|
||||
and generate_prompt_hash(resolved) == generate_prompt_hash(text),
|
||||
"known": True,
|
||||
}
|
||||
|
||||
def _track_prompt_execution(
|
||||
self,
|
||||
text: str,
|
||||
encoding_text: str,
|
||||
category: Optional[str],
|
||||
tags: Optional[list],
|
||||
additional_data: Dict[str, Any],
|
||||
prompt_graph: Any = None,
|
||||
unique_id: Any = None,
|
||||
) -> Optional[int]:
|
||||
"""Save the prompt and, for the run's positive prompt, register it for linking.
|
||||
|
||||
Negative-prompt nodes are saved but never become the prompt their images are
|
||||
linked to. Text the queue hook could resolve is counted there and linked from
|
||||
the image's own metadata; anything else (PromptSearchList batches, unknown
|
||||
text nodes, or a resolution that doesn't match) is counted here and pushed
|
||||
onto the FIFO queue. Without graph context the pre-3.2.4 behaviour is kept.
|
||||
"""
|
||||
role = self._prompt_role(prompt_graph, unique_id, text)
|
||||
batch_item = (
|
||||
role["known"] and role["is_run_prompt"] and not role["seen_at_queue"]
|
||||
)
|
||||
prompt_id = self._save_prompt_to_database(
|
||||
text=text, category=category, tags=tags, count_run=batch_item
|
||||
)
|
||||
if prompt_id and role["is_run_prompt"]:
|
||||
self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=encoding_text,
|
||||
additional_data={**additional_data, "prompt_id": prompt_id},
|
||||
push_to_queue=batch_item or not role["known"],
|
||||
)
|
||||
return prompt_id
|
||||
|
||||
def _record_use(self, prompt_id: int, times: int = 1) -> None:
|
||||
"""Count runs; a tracking failure must never block generation."""
|
||||
try:
|
||||
self.db.record_prompt_use(prompt_id, times=times)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not record use of prompt {prompt_id}: {e}")
|
||||
|
||||
def _inject_lora_trigger_words(self, text: str) -> str:
|
||||
"""Append LoRA trigger words if integration is enabled.
|
||||
|
||||
Returns the text unchanged if the integration is disabled or
|
||||
LoraManager is not installed.
|
||||
"""
|
||||
try:
|
||||
from .py.config import IntegrationConfig
|
||||
except ImportError:
|
||||
try:
|
||||
from py.config import IntegrationConfig
|
||||
except ImportError:
|
||||
return text
|
||||
|
||||
if (
|
||||
not IntegrationConfig.LORA_MANAGER_ENABLED
|
||||
or not IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED
|
||||
):
|
||||
return text
|
||||
|
||||
try:
|
||||
from .py.lora_utils import get_trigger_cache, inject_trigger_words
|
||||
except ImportError:
|
||||
try:
|
||||
from py.lora_utils import get_trigger_cache, inject_trigger_words
|
||||
except ImportError:
|
||||
return text
|
||||
|
||||
cache = get_trigger_cache()
|
||||
|
||||
# Lazy-load cache on first use
|
||||
if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH:
|
||||
cache.load(IntegrationConfig.LORA_MANAGER_PATH)
|
||||
|
||||
modified, injected = inject_trigger_words(text, cache)
|
||||
if injected:
|
||||
self.logger.info(f"Injected trigger words: {', '.join(injected)}")
|
||||
return modified
|
||||
|
||||
def _generate_hash(self, text: str) -> str:
|
||||
"""The prompt's deduplication hash (see utils.hashing.generate_prompt_hash)."""
|
||||
return generate_prompt_hash(text)
|
||||
|
||||
def _parse_tags(self, tags_string: str) -> Optional[list]:
|
||||
"""Parse comma-separated tags string into a list.
|
||||
@@ -163,32 +275,6 @@ class PromptManagerBase:
|
||||
self.logger.error(f"Error searching prompts: {e}")
|
||||
return []
|
||||
|
||||
def _open_web_interface(self):
|
||||
"""Open the web interface in the default browser."""
|
||||
try:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
web_dir = os.path.join(current_dir, "web_interface")
|
||||
|
||||
if os.path.exists(web_dir):
|
||||
index_path = os.path.join(web_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
webbrowser.open(f"file://{index_path}")
|
||||
self.logger.info("Web interface opened in browser")
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Web interface directory found but no index.html. "
|
||||
f"Please check {web_dir} for setup instructions"
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Web interface not yet implemented. This feature will open a "
|
||||
"web-based prompt management interface when the web_interface "
|
||||
"directory is created."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error opening web interface: {e}")
|
||||
|
||||
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""API method for JavaScript UI to search prompts."""
|
||||
return self._search_prompts(search_text=search_text)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Paired positive and negative conditioning with reusable recipe storage."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from .prompt_manager import IO, InputTypeDict, PromptManagerFocused
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.pair_execution import record_pair_execution
|
||||
from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from .utils.prompt_pair_resolver import resolve_text_input
|
||||
from .utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
PENDING_PAIR_FIRST_USE,
|
||||
pair_run_key,
|
||||
)
|
||||
except ImportError:
|
||||
from prompt_manager import IO, InputTypeDict, PromptManagerFocused
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.pair_execution import record_pair_execution
|
||||
from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
from utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
PENDING_PAIR_FIRST_USE,
|
||||
pair_run_key,
|
||||
)
|
||||
|
||||
|
||||
class PromptManagerPair(PromptManagerFocused):
|
||||
"""Keep raw editors as one recipe while encoding their effective texts."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
inputs = super().INPUT_TYPES()
|
||||
inputs["required"] = {
|
||||
"text": inputs["required"]["text"],
|
||||
"negative_text": (
|
||||
IO.STRING,
|
||||
{"default": "", "multiline": True, "dynamicPrompts": True},
|
||||
),
|
||||
"clip": inputs["required"]["clip"],
|
||||
}
|
||||
inputs["hidden"]["extra_pnginfo"] = "EXTRA_PNGINFO"
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = (IO.CONDITIONING, IO.CONDITIONING, IO.STRING, IO.STRING)
|
||||
RETURN_NAMES = ("positive", "negative", "positive_text", "negative_text")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"Positive conditioning with composition and optional LoRA trigger words.",
|
||||
"Negative conditioning from the negative editor, including empty text.",
|
||||
"The effective positive text that was encoded.",
|
||||
"The unchanged negative text that was encoded.",
|
||||
)
|
||||
DESCRIPTION = "Encode and save a reusable positive/negative prompt pair."
|
||||
|
||||
def encode_prompt(
|
||||
self,
|
||||
clip: Any,
|
||||
text: str,
|
||||
negative_text: str = "",
|
||||
category: str = "",
|
||||
tags: str = "",
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
extra_pnginfo: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[Any, Any, str, str]:
|
||||
"""Encode both sides once; database and tracking failures never block CLIP."""
|
||||
if clip is None:
|
||||
raise RuntimeError("ERROR: clip input is invalid: None")
|
||||
if not isinstance(text, str) or not isinstance(negative_text, str):
|
||||
raise ValueError("Positive and negative prompt text must be strings")
|
||||
positive = self._inject_lora_trigger_words(
|
||||
" ".join(
|
||||
part
|
||||
for part in (prepend_text.strip(), text, append_text.strip())
|
||||
if part
|
||||
)
|
||||
)
|
||||
positive_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(positive)
|
||||
)
|
||||
negative_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(negative_text)
|
||||
)
|
||||
record_pair_execution(
|
||||
extra_pnginfo,
|
||||
unique_id,
|
||||
text=text,
|
||||
negative_text=negative_text,
|
||||
positive_text=positive,
|
||||
negative_effective_text=negative_text,
|
||||
prepend_text=prepend_text.strip(),
|
||||
append_text=append_text.strip(),
|
||||
)
|
||||
pair_id = prompt_id = None
|
||||
known = isinstance(prompt, dict) and unique_id is not None
|
||||
node_id = str(unique_id)
|
||||
used_fields = (
|
||||
{
|
||||
field
|
||||
for source, field in run_prompt_components(prompt)
|
||||
if source == node_id
|
||||
}
|
||||
if known
|
||||
else {"text"}
|
||||
)
|
||||
is_positive = "text" in used_fields
|
||||
pair_used = node_id in intrinsic_pair_nodes(prompt) if known else True
|
||||
try:
|
||||
pair_id = self.db.save_prompt_pair(
|
||||
text,
|
||||
negative_text,
|
||||
category=category.strip() or None,
|
||||
tags=self._parse_tags(tags),
|
||||
)
|
||||
pair = self.db.get_prompt_pair(pair_id)
|
||||
prompt_id = pair["positive_prompt_id"]
|
||||
resolved_positive = (
|
||||
resolve_text_input(prompt, node_id, "text") if known else None
|
||||
)
|
||||
resolved_negative = (
|
||||
resolve_text_input(prompt, node_id, "negative_text") if known else None
|
||||
)
|
||||
positive_seen = resolved_positive is not None and generate_prompt_hash(
|
||||
resolved_positive
|
||||
) == generate_prompt_hash(text)
|
||||
pair_seen = (
|
||||
positive_seen
|
||||
and resolved_negative is not None
|
||||
and generate_prompt_hash(resolved_negative)
|
||||
== generate_prompt_hash(negative_text)
|
||||
)
|
||||
positive_pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
|
||||
pair_pending = getattr(
|
||||
self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE
|
||||
)
|
||||
counted = set()
|
||||
for field, component_text, resolved_text, component_id in (
|
||||
("text", text, resolved_positive, prompt_id),
|
||||
(
|
||||
"negative_text",
|
||||
negative_text,
|
||||
resolved_negative,
|
||||
pair["negative_prompt_id"],
|
||||
),
|
||||
):
|
||||
component_hash = generate_prompt_hash(component_text)
|
||||
if (
|
||||
field not in used_fields
|
||||
or not component_id
|
||||
or component_hash in counted
|
||||
):
|
||||
continue
|
||||
counted.add(component_hash)
|
||||
seen_at_queue = (
|
||||
resolved_text is not None
|
||||
and generate_prompt_hash(resolved_text) == component_hash
|
||||
)
|
||||
runs = positive_pending.consume(component_hash) + int(not seen_at_queue)
|
||||
if runs:
|
||||
self.db.record_prompt_use(component_id, times=runs)
|
||||
if pair_used:
|
||||
runs = pair_pending.consume(pair_run_key(text, negative_text)) + int(
|
||||
not pair_seen
|
||||
)
|
||||
if runs:
|
||||
self.db.record_prompt_pair_use(pair_id, times=runs)
|
||||
if is_positive:
|
||||
self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=positive,
|
||||
additional_data={
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"positive_text": positive,
|
||||
"negative_text": negative_text,
|
||||
"positive_storage_text": text.strip(),
|
||||
"negative_storage_text": negative_text.strip(),
|
||||
"prepend_text": prepend_text.strip(),
|
||||
"append_text": append_text.strip(),
|
||||
"final_text": positive,
|
||||
"category": category.strip() or None,
|
||||
"tags": self._parse_tags(tags) or [],
|
||||
},
|
||||
push_to_queue=not positive_seen or (pair_used and not pair_seen),
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not save or track paired prompt: {error}")
|
||||
try:
|
||||
self.comfyui_integration.register_prompt(
|
||||
f"promptmanager_pair_{time.time_ns()}",
|
||||
positive,
|
||||
{
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"negative_text": negative_text,
|
||||
"category": category.strip() or None,
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
return positive_conditioning, negative_conditioning, positive, negative_text
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
clip: Any,
|
||||
text: str = "",
|
||||
negative_text: str = "",
|
||||
category: str = "",
|
||||
tags: str = "",
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Either editor, composition or pair metadata invalidates the node cache."""
|
||||
serialized = json.dumps(
|
||||
[text, negative_text, prepend_text, append_text, category, tags]
|
||||
)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
@@ -4,7 +4,7 @@ without CLIP encoding, while maintaining all database and search features.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Tuple
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
|
||||
@@ -53,7 +53,9 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
{
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
"tooltip": "The text prompt to be processed and saved to database.",
|
||||
"tooltip": (
|
||||
"The text prompt to be processed and saved to database."
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -62,51 +64,74 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Optional category for organizing prompts (e.g., 'landscapes', 'portraits')",
|
||||
"tooltip": (
|
||||
"Optional category for organizing prompts "
|
||||
"(e.g., 'landscapes', 'portraits')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"tags": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated tags for the prompt (e.g., 'anime, detailed, sunset')",
|
||||
"tooltip": (
|
||||
"Comma-separated tags for the prompt "
|
||||
"(e.g., 'anime, detailed, sunset')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"search_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Search for past prompts containing this text",
|
||||
"tooltip": (
|
||||
"Search past prompts by text; add tag:name "
|
||||
'(or tag:"two words") to filter by tag'
|
||||
),
|
||||
},
|
||||
),
|
||||
"prepend_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Text to prepend to the main prompt (connected STRING nodes will be added before the main text)",
|
||||
"tooltip": (
|
||||
"Text to prepend to the main prompt (connected STRING "
|
||||
"nodes will be added before the main text)"
|
||||
),
|
||||
},
|
||||
),
|
||||
"append_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Text to append to the main prompt (connected STRING nodes will be added after the main text)",
|
||||
"tooltip": (
|
||||
"Text to append to the main prompt (connected STRING "
|
||||
"nodes will be added after the main text)"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
# prompt of a run from negative-prompt PromptManager nodes
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.STRING,)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The final combined text string (with prepend/append applied) ready for use in other nodes.",
|
||||
"The final combined text string (with prepend/append applied) ready for use "
|
||||
"in other nodes.",
|
||||
)
|
||||
FUNCTION = "process_text"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
DESCRIPTION = (
|
||||
"Processes and manages text prompts with database storage and search capabilities. "
|
||||
"Outputs a plain STRING that can be used with any node that accepts text input. "
|
||||
"Includes all PromptManager features: categorization, tagging, search, and prepend/append functionality."
|
||||
"Processes and manages text prompts with database storage and search "
|
||||
"capabilities. Outputs a plain STRING that can be used with any node that "
|
||||
"accepts text input. Includes all PromptManager features: categorization, "
|
||||
"tagging, search, and prepend/append functionality."
|
||||
)
|
||||
|
||||
def process_text(
|
||||
@@ -117,6 +142,8 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
) -> Tuple[str]:
|
||||
"""
|
||||
Process the text prompt and save it to the database.
|
||||
@@ -142,7 +169,11 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
parts.append(append_text.strip())
|
||||
final_text = " ".join(parts)
|
||||
|
||||
# For database storage, save the original main text with metadata about prepend/append
|
||||
# Inject LoRA trigger words if integration is enabled
|
||||
final_text = self._inject_lora_trigger_words(final_text)
|
||||
|
||||
# For database storage, save the original main text with metadata
|
||||
# about prepend/append
|
||||
storage_text = text
|
||||
|
||||
# Save prompt to database and set execution context for gallery tracking
|
||||
@@ -159,29 +190,23 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
extended_tags.append(f"append:{append_text.strip()[:50]}")
|
||||
|
||||
try:
|
||||
prompt_id = self._save_prompt_to_database(
|
||||
prompt_id = self._track_prompt_execution(
|
||||
text=storage_text.strip(),
|
||||
encoding_text=final_text.strip(),
|
||||
category=category.strip() if category else None,
|
||||
tags=extended_tags if extended_tags else None,
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
prompt_graph=prompt,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
# Set current prompt for image tracking
|
||||
if prompt_id:
|
||||
execution_id = self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=final_text.strip(),
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Set execution context: {execution_id} for prompt ID: {prompt_id}"
|
||||
)
|
||||
self.logger.debug(f"Tracked prompt ID: {prompt_id}")
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the processing
|
||||
@@ -218,10 +243,11 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
"""
|
||||
ComfyUI method to determine if node needs re-execution.
|
||||
|
||||
Returns a hash of input values that affect the text output.
|
||||
This enables proper branch execution - only re-execute when inputs change.
|
||||
Returns a hash of input values that affect the text output or the
|
||||
saved prompt's metadata. This enables proper branch execution - only
|
||||
re-execute when inputs change.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
combined = f"{text}|{prepend_text}|{append_text}"
|
||||
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()
|
||||
|
||||
@@ -5,7 +5,7 @@ This node enables batch processing workflows by outputting prompt texts with
|
||||
OUTPUT_IS_LIST=True, allowing direct connection to nodes that accept list inputs.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
import re
|
||||
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
@@ -79,7 +79,9 @@ class PromptSearchList(ComfyNodeABC):
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated list of tags to filter by",
|
||||
"tooltip": (
|
||||
"Comma-separated list of tags to filter by (partial match)"
|
||||
),
|
||||
},
|
||||
),
|
||||
"min_rating": (
|
||||
@@ -100,20 +102,36 @@ class PromptSearchList(ComfyNodeABC):
|
||||
"tooltip": "Maximum number of results to return",
|
||||
},
|
||||
),
|
||||
"skip_multipart": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"Skip prompts containing Clip_1/Clip_2/etc. "
|
||||
"multi-part markers"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.STRING,)
|
||||
RETURN_NAMES = ("prompts",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
OUTPUT_TOOLTIPS = ("List of prompt texts matching the search criteria.",)
|
||||
RETURN_TYPES = (IO.STRING, IO.STRING, IO.INT)
|
||||
RETURN_NAMES = ("prompts", "preview", "count")
|
||||
OUTPUT_IS_LIST = (True, False, False)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"List of prompt texts matching the search criteria.",
|
||||
"Preview of all found prompts (for display).",
|
||||
"Number of prompts found.",
|
||||
)
|
||||
FUNCTION = "search"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
DESCRIPTION = (
|
||||
"Searches the prompt database and outputs matching prompts as a list. "
|
||||
"Use this node to retrieve stored prompts for batch processing workflows. "
|
||||
"Connect to nodes that accept list inputs like String OutputList or batch processors."
|
||||
"'prompts' output sends each prompt individually to downstream nodes (batch). "
|
||||
"'preview' output shows all found prompts as one text block. "
|
||||
"'count' output shows how many prompts were found."
|
||||
)
|
||||
|
||||
def search(
|
||||
@@ -123,7 +141,8 @@ class PromptSearchList(ComfyNodeABC):
|
||||
tags: str = "",
|
||||
min_rating: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[List[str]]:
|
||||
skip_multipart: bool = True,
|
||||
):
|
||||
"""
|
||||
Search for prompts matching the given criteria.
|
||||
|
||||
@@ -135,7 +154,7 @@ class PromptSearchList(ComfyNodeABC):
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
Tuple containing a list of prompt text strings
|
||||
Dict with 'ui' display info and 'result' tuple
|
||||
"""
|
||||
try:
|
||||
# Parse tags from comma-separated string
|
||||
@@ -143,38 +162,69 @@ class PromptSearchList(ComfyNodeABC):
|
||||
if tags and tags.strip():
|
||||
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
|
||||
# Perform search
|
||||
# Perform search with partial tag matching for discovery
|
||||
results = self.db.search_prompts(
|
||||
text=text.strip() if text and text.strip() else None,
|
||||
category=category.strip() if category and category.strip() else None,
|
||||
tags=tags_list,
|
||||
tag_partial=True,
|
||||
rating_min=min_rating if min_rating > 0 else None,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Extract just the prompt text from results
|
||||
prompt_texts = [r["text"] for r in results if r.get("text")]
|
||||
# Extract prompt text, collapsing newlines to spaces so downstream
|
||||
# nodes that split on \n (e.g. StringOutputList) treat each DB
|
||||
# entry as a single prompt.
|
||||
prompt_texts = [
|
||||
" ".join(r["text"].split()) for r in results if r.get("text")
|
||||
]
|
||||
|
||||
self.logger.debug(
|
||||
f"Search returned {len(prompt_texts)} prompts "
|
||||
f"(text='{text[:20]}...' if text else '', category='{category}', "
|
||||
f"tags={tags_list}, min_rating={min_rating}, limit={limit})"
|
||||
)
|
||||
# Filter out multi-part prompts (Clip_1/Clip_2 markers)
|
||||
if skip_multipart:
|
||||
prompt_texts = [
|
||||
p for p in prompt_texts if not re.search(r"Clip_\d+", p)
|
||||
]
|
||||
|
||||
# Return empty list if no results (not an error)
|
||||
# Filter out prompts that are only LoRA tags with no actual content.
|
||||
# Uses subtraction instead of repeated groups to avoid backtracking.
|
||||
prompt_texts = [
|
||||
p for p in prompt_texts if re.sub(r"<lora:[^>]+>", "", p).strip()
|
||||
]
|
||||
|
||||
count = len(prompt_texts)
|
||||
|
||||
# Build a preview: numbered list of truncated prompts
|
||||
preview_lines = []
|
||||
for i, p in enumerate(prompt_texts, 1):
|
||||
truncated = p[:120] + "..." if len(p) > 120 else p
|
||||
preview_lines.append(f"[{i}] {truncated}")
|
||||
preview = "\n".join(preview_lines) if preview_lines else "No results found"
|
||||
|
||||
# Must return at least one element — ComfyUI's slice_dict
|
||||
# indexes into OUTPUT_IS_LIST outputs and crashes on empty lists.
|
||||
if not prompt_texts:
|
||||
self.logger.info("Search returned no results")
|
||||
return ([],)
|
||||
return {
|
||||
"ui": {"text": ["No results found"]},
|
||||
"result": ([""], preview, count),
|
||||
}
|
||||
|
||||
return (prompt_texts,)
|
||||
return {
|
||||
"ui": {"text": [f"Found {count} prompts"]},
|
||||
"result": (prompt_texts, preview, count),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Search error: {e}", exc_info=True)
|
||||
# Return empty list on error to avoid breaking workflows
|
||||
return ([],)
|
||||
return {
|
||||
"ui": {"text": [f"Search error: {e}"]},
|
||||
"result": ([""], f"Error: {e}", 0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text="", category="", tags="", min_rating=0, limit=50):
|
||||
def IS_CHANGED(
|
||||
cls, text="", category="", tags="", min_rating=0, limit=50, skip_multipart=True
|
||||
):
|
||||
"""
|
||||
Always re-execute to get fresh results from the database.
|
||||
|
||||
|
||||
@@ -13,21 +13,26 @@ import datetime
|
||||
import functools
|
||||
import gzip as gzip_module
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.video_metadata import is_video_path, read_video_metadata
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
@@ -36,6 +41,7 @@ except ImportError:
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.logging_config import get_logger
|
||||
from utils.video_metadata import is_video_path, read_video_metadata
|
||||
|
||||
|
||||
def _get_project_root():
|
||||
@@ -43,6 +49,148 @@ def _get_project_root():
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _public_path(path) -> str:
|
||||
"""Render a server path for an API response without revealing the layout.
|
||||
|
||||
Returns the path relative to the ComfyUI base directory (or to the parent
|
||||
of an opted-in extra gallery root), as a POSIX-style string, or just the
|
||||
basename when the path is not under any of those anchors.
|
||||
"""
|
||||
if not path:
|
||||
return ""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
try:
|
||||
canonical = Path(os.path.normcase(os.path.realpath(str(path))))
|
||||
except (OSError, ValueError):
|
||||
return os.path.basename(str(path))
|
||||
for anchor in GalleryConfig.path_anchors():
|
||||
if canonical.is_relative_to(Path(anchor)):
|
||||
return canonical.relative_to(Path(anchor)).as_posix() or "."
|
||||
return canonical.name
|
||||
|
||||
|
||||
def _public_error(exc: BaseException) -> str:
|
||||
"""Error text safe for API responses: OSError paths reduced to basenames."""
|
||||
if isinstance(exc, OSError):
|
||||
parts = [exc.strerror or type(exc).__name__]
|
||||
if exc.filename:
|
||||
parts.append(os.path.basename(str(exc.filename)))
|
||||
return ": ".join(parts)
|
||||
return str(exc) or type(exc).__name__
|
||||
|
||||
|
||||
# ── Static file serving ────────────────────────────────────────────────
|
||||
# Only these extensions are ever served from web/lib and web/js; the map
|
||||
# doubles as the allow-list.
|
||||
STATIC_MIME_TYPES = {
|
||||
".js": "application/javascript",
|
||||
".mjs": "application/javascript",
|
||||
".css": "text/css",
|
||||
".map": "application/json",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".html": "text/html",
|
||||
}
|
||||
_STATIC_SEPARATORS = re.compile(r"[\\/]+")
|
||||
|
||||
|
||||
def _resolve_static_file(root, filepath):
|
||||
"""Real path of ``filepath`` under ``root``, or None when it must not be served.
|
||||
|
||||
The request path is untrusted. It is rejected when it is empty, carries a
|
||||
``..`` segment, is absolute on any platform (a leading slash or backslash,
|
||||
a drive letter such as ``C:`` or a UNC ``\\\\server\\share`` prefix all
|
||||
re-anchor ``os.path.join`` on Windows), has an extension outside
|
||||
STATIC_MIME_TYPES, or resolves (symlinks included) outside ``root``.
|
||||
"""
|
||||
if not isinstance(filepath, str) or not filepath:
|
||||
return None
|
||||
if (
|
||||
os.path.isabs(filepath)
|
||||
or ntpath.isabs(filepath)
|
||||
or ntpath.splitdrive(filepath)[0]
|
||||
):
|
||||
return None
|
||||
segments = _STATIC_SEPARATORS.split(filepath)
|
||||
if any(segment in ("", ".", "..") for segment in segments):
|
||||
return None
|
||||
if os.path.splitext(filepath)[1].lower() not in STATIC_MIME_TYPES:
|
||||
return None
|
||||
try:
|
||||
root_real = Path(os.path.normcase(os.path.realpath(root)))
|
||||
candidate = Path(
|
||||
os.path.normcase(os.path.realpath(os.path.join(root, *segments)))
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if candidate == root_real or not candidate.is_relative_to(root_real):
|
||||
return None
|
||||
return str(candidate)
|
||||
|
||||
|
||||
# ── JSON request bodies ────────────────────────────────────────────────
|
||||
JSON_BODY_MAX_BYTES = 1_000_000
|
||||
_JSON_READ_CHUNK = 64 * 1024
|
||||
|
||||
|
||||
def _json_error(message, status):
|
||||
return web.json_response({"success": False, "error": message}, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request, max_bytes=JSON_BODY_MAX_BYTES):
|
||||
"""Read and parse a JSON object body of at most ``max_bytes``.
|
||||
|
||||
The body is read in chunks so an oversized or chunked upload is refused
|
||||
as soon as it crosses the cap rather than buffered whole.
|
||||
|
||||
Returns:
|
||||
(data, None) on success, or (None, response) where ``response`` is a
|
||||
413 (body too large) or 400 (invalid JSON, or not a JSON object).
|
||||
"""
|
||||
too_large = _json_error(f"Request body too large (max {max_bytes} bytes)", 413)
|
||||
declared = request.content_length
|
||||
if declared is not None and declared > max_bytes:
|
||||
return None, too_large
|
||||
|
||||
chunks = []
|
||||
size = 0
|
||||
async for chunk in request.content.iter_chunked(_JSON_READ_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
return None, too_large
|
||||
chunks.append(chunk)
|
||||
|
||||
try:
|
||||
data = json.loads(b"".join(chunks).decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None, _json_error("Request body must be valid JSON", 400)
|
||||
if not isinstance(data, dict):
|
||||
return None, _json_error("Request body must be a JSON object", 400)
|
||||
return data, None
|
||||
|
||||
|
||||
class _LongJobGuard:
|
||||
"""Async context manager handed out by ``_acquire_long_job``."""
|
||||
|
||||
def __init__(self, lock):
|
||||
self._lock = lock
|
||||
|
||||
async def __aenter__(self):
|
||||
# The caller checked ``locked()`` on this same loop iteration, so this
|
||||
# acquire never waits.
|
||||
await self._lock.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self._lock.release()
|
||||
return False
|
||||
|
||||
|
||||
# ── Gzip compression middleware ────────────────────────────────────────
|
||||
_GZIP_MIN_SIZE = 1024 # Only compress bodies larger than 1 KB
|
||||
_GZIP_TYPES = frozenset(
|
||||
@@ -94,11 +242,13 @@ async def _gzip_middleware(request, handler):
|
||||
|
||||
|
||||
class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
LoggingRoutesMixin,
|
||||
AutotagRoutesMixin,
|
||||
LoraIntegrationMixin,
|
||||
):
|
||||
"""REST API handler for PromptManager operations and web interface.
|
||||
|
||||
@@ -115,16 +265,24 @@ class PromptManagerAPI(
|
||||
db (PromptDatabase): Database connection and operations handler
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the PromptManager API with database connection and cleanup."""
|
||||
def __init__(self, db: PromptDatabase | None = None) -> None:
|
||||
"""Initialize the API, using an injected database before any cleanup."""
|
||||
self.logger = get_logger("prompt_manager.api")
|
||||
self.logger.info("Initializing PromptManager API")
|
||||
|
||||
self.db = PromptDatabase()
|
||||
self.db = db if db is not None else PromptDatabase()
|
||||
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
self._gallery_cache_ttl = 30 # Cache TTL in seconds
|
||||
# Single-flight locks for long jobs, keyed by job family; see
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
self._long_jobs: dict = {}
|
||||
# The current output scan (py/api/scan_job.ScanJob), if one was started.
|
||||
self._scan_job = None
|
||||
# The current duplicate scan (also a ScanJob), if one was started.
|
||||
self._duplicate_job = None
|
||||
|
||||
# Run cleanup on initialization to remove any existing duplicates
|
||||
try:
|
||||
@@ -138,6 +296,35 @@ class PromptManagerAPI(
|
||||
|
||||
self.logger.info("PromptManager API initialization completed")
|
||||
|
||||
_public_path = staticmethod(_public_path)
|
||||
_public_error = staticmethod(_public_error)
|
||||
_read_json_body = staticmethod(_read_json_body)
|
||||
|
||||
def _acquire_long_job(self, name):
|
||||
"""Claim the single-flight slot for a long job family, or None when busy.
|
||||
|
||||
Usage in a handler::
|
||||
|
||||
job = self._acquire_long_job("duplicates")
|
||||
if job is None:
|
||||
return self._long_job_busy_response("Duplicate scan")
|
||||
async with job:
|
||||
...
|
||||
|
||||
Families are free-form strings ("duplicates", "thumbnails",
|
||||
"autotag", ...); the lock is released when the ``async with`` block
|
||||
exits, including on exceptions. Must be called from the event loop.
|
||||
"""
|
||||
lock = self._long_jobs.setdefault(name, asyncio.Lock())
|
||||
if lock.locked():
|
||||
return None
|
||||
return _LongJobGuard(lock)
|
||||
|
||||
@staticmethod
|
||||
def _long_job_busy_response(label):
|
||||
"""409 envelope for a long job that is already running."""
|
||||
return _json_error(f"{label} already running", 409)
|
||||
|
||||
async def _run_in_executor(self, func, *args, **kwargs):
|
||||
"""Run a blocking function in the default thread pool executor.
|
||||
|
||||
@@ -177,201 +364,47 @@ class PromptManagerAPI(
|
||||
|
||||
# ── Web UI serving routes ─────────────────────────────────────
|
||||
|
||||
@routes.get("/prompt_manager/workspace")
|
||||
async def serve_workspace_ui(request: web.Request) -> web.Response:
|
||||
return self._serve_html_page("workspace.html", "Workspace")
|
||||
|
||||
@routes.get("/prompt_manager/web")
|
||||
async def serve_web_ui(request):
|
||||
try:
|
||||
html_path = os.path.join(_get_project_root(), "web", "index.html")
|
||||
|
||||
if os.path.exists(html_path):
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
html_content = f.read()
|
||||
|
||||
return web.Response(
|
||||
text=html_content, content_type="text/html", charset="utf-8"
|
||||
)
|
||||
else:
|
||||
return web.Response(
|
||||
text="<h1>Web UI not found</h1><p>HTML file not located at expected path.</p>",
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception("Failed to load web UI")
|
||||
return web.Response(
|
||||
text="<h1>Error</h1><p>Failed to load web UI. Check server logs for details.</p>",
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
return self._serve_html_page("index.html", "Web UI", cache=False)
|
||||
|
||||
@routes.get("/prompt_manager/gallery.html")
|
||||
async def serve_gallery_ui(request):
|
||||
try:
|
||||
html_path = os.path.join(
|
||||
_get_project_root(),
|
||||
"web",
|
||||
"metadata.html",
|
||||
)
|
||||
|
||||
if html_path not in self._html_cache:
|
||||
if os.path.exists(html_path):
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
self._html_cache[html_path] = f.read()
|
||||
else:
|
||||
return web.Response(
|
||||
text="<h1>Gallery not found</h1><p>gallery.html file not located at expected path.</p>",
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
text=self._html_cache[html_path],
|
||||
content_type="text/html",
|
||||
charset="utf-8",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception("Failed to load gallery")
|
||||
return web.Response(
|
||||
text="<h1>Error</h1><p>Failed to load gallery. Check server logs for details.</p>",
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
return self._serve_html_page("metadata.html", "Gallery")
|
||||
|
||||
@routes.get("/prompt_manager/admin")
|
||||
async def serve_admin_ui(request):
|
||||
try:
|
||||
html_path = os.path.join(
|
||||
_get_project_root(),
|
||||
"web",
|
||||
"admin.html",
|
||||
)
|
||||
|
||||
if html_path not in self._html_cache:
|
||||
if os.path.exists(html_path):
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
self._html_cache[html_path] = f.read()
|
||||
else:
|
||||
return web.Response(
|
||||
text="<h1>Admin UI not found</h1>",
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
text=self._html_cache[html_path],
|
||||
content_type="text/html",
|
||||
charset="utf-8",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception("Failed to load admin UI")
|
||||
return web.Response(
|
||||
text="<h1>Error</h1><p>Failed to load admin UI. Check server logs for details.</p>",
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
return self._serve_html_page("admin.html", "Admin UI")
|
||||
|
||||
@routes.get("/prompt_manager/gallery")
|
||||
async def serve_gallery_admin_ui(request):
|
||||
try:
|
||||
html_path = os.path.join(
|
||||
_get_project_root(),
|
||||
"web",
|
||||
"gallery.html",
|
||||
)
|
||||
|
||||
if html_path not in self._html_cache:
|
||||
if os.path.exists(html_path):
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
self._html_cache[html_path] = f.read()
|
||||
else:
|
||||
return web.Response(
|
||||
text="<h1>Gallery not found</h1><p>gallery.html file not located at expected path.</p>",
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
text=self._html_cache[html_path],
|
||||
content_type="text/html",
|
||||
charset="utf-8",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception("Failed to load gallery")
|
||||
return web.Response(
|
||||
text="<h1>Error</h1><p>Failed to load gallery. Check server logs for details.</p>",
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
return self._serve_html_page("gallery.html", "Gallery")
|
||||
|
||||
# ── Static file serving ───────────────────────────────────────
|
||||
|
||||
@routes.get("/prompt_manager/lib/{filepath:.*}")
|
||||
async def serve_lib_static(request):
|
||||
"""Serve static library files (JS, CSS) from web/lib directory."""
|
||||
MIME_TYPES = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".map": "application/json",
|
||||
}
|
||||
|
||||
filepath = request.match_info.get("filepath", "")
|
||||
|
||||
# Security: prevent directory traversal
|
||||
if ".." in filepath or filepath.startswith("/"):
|
||||
return web.Response(text="Forbidden", status=403)
|
||||
|
||||
file_path = os.path.join(_get_project_root(), "web", "lib", filepath)
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(text=f"Not Found: {filepath}", status=404)
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
content_type = MIME_TYPES.get(ext, "application/octet-stream")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
return web.Response(body=content, content_type=content_type)
|
||||
return self._serve_static_file("lib", request.match_info.get("filepath"))
|
||||
|
||||
@routes.get("/prompt_manager/js/{filepath:.*}")
|
||||
async def serve_js_static(request):
|
||||
"""Serve static JavaScript files from web/js directory."""
|
||||
MIME_TYPES = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".map": "application/json",
|
||||
}
|
||||
|
||||
filepath = request.match_info.get("filepath", "")
|
||||
|
||||
if ".." in filepath or filepath.startswith("/"):
|
||||
return web.Response(text="Forbidden", status=403)
|
||||
|
||||
file_path = os.path.join(_get_project_root(), "web", "js", filepath)
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(text=f"Not Found: {filepath}", status=404)
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
content_type = MIME_TYPES.get(ext, "application/octet-stream")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
return web.Response(body=content, content_type=content_type)
|
||||
return self._serve_static_file("js", request.match_info.get("filepath"))
|
||||
|
||||
# ── Register domain-specific routes from mixins ───────────────
|
||||
|
||||
self._register_pair_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
self._register_admin_routes(routes)
|
||||
self._register_logging_routes(routes)
|
||||
self._register_autotag_routes(routes)
|
||||
self._register_lora_routes(routes)
|
||||
|
||||
# Register gzip compression middleware (once)
|
||||
global _gzip_registered
|
||||
@@ -387,6 +420,47 @@ class PromptManagerAPI(
|
||||
|
||||
self.logger.info("All routes registered with decorator pattern")
|
||||
|
||||
def _serve_static_file(self, subdir, filepath):
|
||||
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
|
||||
root = os.path.join(_get_project_root(), "web", subdir)
|
||||
file_path = _resolve_static_file(root, filepath)
|
||||
if file_path is None:
|
||||
return web.Response(text="Forbidden", status=403)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.Response(text="Not Found", status=404)
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
return web.Response(body=content, content_type=STATIC_MIME_TYPES[ext])
|
||||
|
||||
def _serve_html_page(self, filename, title, cache=True):
|
||||
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
|
||||
html_path = os.path.join(_get_project_root(), "web", filename)
|
||||
try:
|
||||
if cache and html_path in self._html_cache:
|
||||
content = self._html_cache[html_path]
|
||||
elif os.path.exists(html_path):
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if cache:
|
||||
self._html_cache[html_path] = content
|
||||
else:
|
||||
return web.Response(
|
||||
text=f"<h1>{title} not found</h1>"
|
||||
"<p>HTML file not located at expected path.</p>",
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
)
|
||||
return web.Response(text=content, content_type="text/html", charset="utf-8")
|
||||
except Exception:
|
||||
self.logger.exception(f"Failed to load {title}")
|
||||
return web.Response(
|
||||
text=f"<h1>Error</h1><p>Failed to load {title}. "
|
||||
"Check server logs for details.</p>",
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
|
||||
# ── Shared utilities used by multiple mixins ──────────────────────
|
||||
|
||||
def _enrich_images(self, images):
|
||||
@@ -412,7 +486,8 @@ class PromptManagerAPI(
|
||||
rel_path = img_path.resolve().relative_to(output_path.resolve())
|
||||
image["relative_path"] = str(rel_path)
|
||||
image["url"] = (
|
||||
f"/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
"/prompt_manager/images/serve/"
|
||||
f"{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
)
|
||||
|
||||
# Check for thumbnail
|
||||
@@ -420,11 +495,13 @@ class PromptManagerAPI(
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
|
||||
)
|
||||
thumb_abs = output_path / thumb_rel
|
||||
if thumb_abs.exists():
|
||||
image["thumbnail_url"] = (
|
||||
f"/prompt_manager/images/serve/{url_quote(thumb_rel, safe='/')}"
|
||||
)
|
||||
from .prompts import thumbnail_url_for
|
||||
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
)
|
||||
if thumbnail_url:
|
||||
image["thumbnail_url"] = thumbnail_url
|
||||
break # Found matching root, stop searching
|
||||
except (ValueError, RuntimeError):
|
||||
continue # Try next root
|
||||
@@ -473,17 +550,18 @@ class PromptManagerAPI(
|
||||
from ..config import GalleryConfig
|
||||
|
||||
if GalleryConfig.MONITORING_DIRECTORIES:
|
||||
configured_dir = Path(GalleryConfig.MONITORING_DIRECTORIES[0]).resolve()
|
||||
if configured_dir.exists() and configured_dir.is_dir():
|
||||
configured = GalleryConfig.MONITORING_DIRECTORIES[0]
|
||||
# Same check as the settings endpoint: a root hand-edited
|
||||
# into config.json must not open a directory outside ComfyUI
|
||||
ok, reason = GalleryConfig.validate_gallery_root(configured)
|
||||
if ok:
|
||||
configured_dir = Path(configured).resolve()
|
||||
self.logger.info(
|
||||
f"Using configured monitoring directory: {configured_dir}"
|
||||
)
|
||||
self._cached_output_dir = str(configured_dir)
|
||||
return self._cached_output_dir
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Configured directory does not exist: {GalleryConfig.MONITORING_DIRECTORIES[0]}"
|
||||
)
|
||||
self.logger.warning(f"Ignoring configured directory: {reason}")
|
||||
except ImportError:
|
||||
self.logger.debug(
|
||||
"GalleryConfig not available, skipping configured directory check"
|
||||
@@ -503,7 +581,8 @@ class PromptManagerAPI(
|
||||
output_dir = current_dir / "output"
|
||||
if output_dir.exists() and output_dir.is_dir():
|
||||
self.logger.debug(
|
||||
f"Found ComfyUI output directory via upward search: {output_dir}"
|
||||
"Found ComfyUI output directory via upward search: "
|
||||
f"{output_dir}"
|
||||
)
|
||||
self._cached_output_dir = str(output_dir)
|
||||
return self._cached_output_dir
|
||||
@@ -554,39 +633,72 @@ class PromptManagerAPI(
|
||||
return None
|
||||
|
||||
def _get_all_output_dirs(self):
|
||||
"""Get all configured output directories, falling back to auto-detect.
|
||||
"""Get the directories images may be served from.
|
||||
|
||||
Configured gallery roots are filtered through
|
||||
``GalleryConfig.validate_gallery_root`` so a root that was hand-edited
|
||||
into config.json (or persisted before validation existed) can never
|
||||
expose files outside ComfyUI's directories.
|
||||
|
||||
Returns:
|
||||
List[Path]: Valid output directory paths, possibly empty.
|
||||
List[Path]: Validated directory paths, possibly empty.
|
||||
"""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
output_dirs = []
|
||||
if GalleryConfig.MONITORING_DIRECTORIES:
|
||||
for d in GalleryConfig.MONITORING_DIRECTORIES:
|
||||
p = Path(d).resolve()
|
||||
if p.is_dir():
|
||||
output_dirs.append(p)
|
||||
seen = set()
|
||||
for d in configured:
|
||||
ok, reason = GalleryConfig.validate_gallery_root(d)
|
||||
if not ok:
|
||||
self.logger.warning(f"Ignoring configured gallery root: {reason}")
|
||||
continue
|
||||
real = os.path.realpath(d)
|
||||
key = os.path.normcase(real) # one entry per directory, however spelled
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
output_dirs.append(Path(real))
|
||||
|
||||
# Fallback to auto-detect if no configured dirs are valid
|
||||
if not output_dirs:
|
||||
fallback = self._find_comfyui_output_dir()
|
||||
if fallback:
|
||||
output_dirs.append(Path(fallback))
|
||||
if output_dirs:
|
||||
return output_dirs
|
||||
|
||||
return output_dirs
|
||||
if configured:
|
||||
# Every configured root was rejected: never let auto-detection
|
||||
# resurrect it, serve ComfyUI's own output directory instead.
|
||||
return self._comfyui_output_dir_list()
|
||||
|
||||
fallback = self._find_comfyui_output_dir()
|
||||
return [Path(fallback)] if fallback else []
|
||||
|
||||
def _comfyui_output_dir_list(self):
|
||||
"""ComfyUI's output directory as a one-element list, or [] if unknown."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return []
|
||||
if isinstance(output_dir, str) and os.path.isdir(output_dir):
|
||||
return [Path(os.path.realpath(output_dir))]
|
||||
return []
|
||||
|
||||
def _extract_comfyui_metadata(self, image_path):
|
||||
"""Extract ComfyUI workflow metadata from PNG image files."""
|
||||
"""Embedded ComfyUI metadata of an image (PNG text chunks) or a video.
|
||||
|
||||
Videos go through ffprobe (see utils/video_metadata). Unreadable files
|
||||
are a warning, not an error: a scan over a large output tree meets
|
||||
truncated and foreign files, and it carries on past them.
|
||||
"""
|
||||
if is_video_path(image_path):
|
||||
return read_video_metadata(image_path)
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
metadata = {}
|
||||
if hasattr(img, "text"):
|
||||
for key, value in img.text.items():
|
||||
metadata[key] = value
|
||||
return metadata
|
||||
return dict(getattr(img, "text", None) or {})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading {image_path}: {e}")
|
||||
self.logger.warning(
|
||||
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
def _parse_comfyui_prompt(self, metadata):
|
||||
@@ -599,18 +711,43 @@ class PromptManagerAPI(
|
||||
"negative_prompt": None,
|
||||
}
|
||||
|
||||
# Check for A1111 style parameters first (like parse-metadata.py)
|
||||
# Check for A1111/Forge style parameters first.
|
||||
# The chunk is one block: a possibly multi-line positive prompt, then a
|
||||
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
# Taking lines[0] as the positive prompt truncates any multi-line prompt
|
||||
# to one line, so split on the settings line instead.
|
||||
if "parameters" in metadata:
|
||||
import re as _re
|
||||
|
||||
params = metadata["parameters"]
|
||||
lines = params.splitlines()
|
||||
if lines:
|
||||
result["positive_prompt"] = lines[0].strip()
|
||||
for line in lines:
|
||||
if line.lower().startswith("negative prompt:"):
|
||||
result["negative_prompt"] = line.split(":", 1)[1].strip()
|
||||
break
|
||||
body = params
|
||||
settings = ""
|
||||
_sm = _re.search(r"\nSteps:\s", body)
|
||||
if _sm:
|
||||
settings = body[_sm.start() + 1 :].strip()
|
||||
body = body[: _sm.start()]
|
||||
negative = None
|
||||
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if _nm:
|
||||
negative = body[_nm.end() :].strip()
|
||||
body = body[: _nm.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
# Store raw parameters too
|
||||
result["parameters"]["parameters"] = params
|
||||
if settings:
|
||||
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
|
||||
_fm = _re.search(
|
||||
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if _fm:
|
||||
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
|
||||
1
|
||||
).strip()
|
||||
|
||||
# If no A1111 format found, proceed with ComfyUI parsing
|
||||
if result["positive_prompt"] is None:
|
||||
@@ -707,7 +844,8 @@ class PromptManagerAPI(
|
||||
|
||||
New format: inputs is a list of connection objects
|
||||
inputs = [
|
||||
{"name": "text", "type": "STRING", "link": null, "widget": {"name": "text"}},
|
||||
{"name": "text", "type": "STRING", "link": null,
|
||||
"widget": {"name": "text"}},
|
||||
{"name": "clip", "type": "CLIP", "link": 11}
|
||||
]
|
||||
"""
|
||||
@@ -770,7 +908,10 @@ class PromptManagerAPI(
|
||||
return None
|
||||
|
||||
def _extract_positive_prompt_from_comfyui_data(self, data):
|
||||
"""Extract positive prompt from ComfyUI data, handling both old and new formats."""
|
||||
"""Extract positive prompt from ComfyUI data.
|
||||
|
||||
Handles both old and new formats.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
|
||||
@@ -2,10 +2,53 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
|
||||
_BUSY_ERROR = "Auto-tagging is already running"
|
||||
|
||||
# Only these may be handed to the tagging engine or listed by the scanner.
|
||||
_MEDIA_EXTENSIONS = frozenset(
|
||||
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif"}
|
||||
)
|
||||
# Bounds for scan_output_dir: a runaway output tree must not pin the server.
|
||||
_SCAN_MAX_FILES = 50_000
|
||||
_SCAN_MAX_DEPTH = 12
|
||||
|
||||
# One batch run at a time; a second autotag/start gets 409 while this is held.
|
||||
_autotag_batch_lock = threading.Lock()
|
||||
|
||||
|
||||
def _file_exists(path) -> bool:
|
||||
try:
|
||||
return Path(str(path)).exists()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def path_is_within(candidate, root) -> bool:
|
||||
"""True when ``candidate`` is strictly inside ``root`` on the real filesystem.
|
||||
|
||||
Both sides go through ``os.path.realpath`` (symlinks, ``..``) and
|
||||
``os.path.normcase`` (Windows case/separator folding) before the
|
||||
``Path.is_relative_to`` check, so ``/out2`` is never treated as inside
|
||||
``/out`` and a symlink inside ``root`` pointing elsewhere is rejected.
|
||||
"""
|
||||
try:
|
||||
child = Path(os.path.normcase(os.path.realpath(str(candidate))))
|
||||
parent = Path(os.path.normcase(os.path.realpath(str(root))))
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
return child != parent and child.is_relative_to(parent)
|
||||
|
||||
|
||||
class AutotagRoutesMixin:
|
||||
"""Mixin providing auto-tagging API endpoints."""
|
||||
@@ -15,11 +58,11 @@ class AutotagRoutesMixin:
|
||||
async def get_autotag_models_route(request):
|
||||
return await self.get_autotag_models(request)
|
||||
|
||||
@routes.get("/prompt_manager/autotag/download/{model_type}")
|
||||
@routes.post("/prompt_manager/autotag/download/{model_type}")
|
||||
async def download_autotag_model_route(request):
|
||||
return await self.download_autotag_model(request)
|
||||
|
||||
@routes.get("/prompt_manager/autotag/start")
|
||||
@routes.post("/prompt_manager/autotag/start")
|
||||
async def start_autotag_route(request):
|
||||
return await self.start_autotag(request)
|
||||
|
||||
@@ -61,253 +104,17 @@ class AutotagRoutesMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get autotag models error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def download_autotag_model(self, request):
|
||||
"""Download an AutoTag model with streaming progress."""
|
||||
model_type = request.match_info.get("model_type")
|
||||
|
||||
async def stream_response():
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
|
||||
if model_type not in service.models_config:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'Invalid model type: {model_type}'})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': 'Starting download...'})}\n\n"
|
||||
|
||||
progress_data = {"last_progress": 0}
|
||||
|
||||
def progress_callback(status: str, progress: float):
|
||||
progress_data["last_progress"] = progress
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await loop.run_in_executor(
|
||||
None, lambda: service.download_model(model_type, progress_callback)
|
||||
)
|
||||
|
||||
if success:
|
||||
yield f"data: {json.dumps({'type': 'complete', 'progress': 100, 'status': 'Download complete'})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Download failed'})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception("Download model error")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'An internal error occurred. Check server logs for details.'})}\n\n"
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
await response.prepare(request)
|
||||
|
||||
async for chunk in stream_response():
|
||||
await response.write(chunk.encode("utf-8"))
|
||||
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
async def start_autotag(self, request):
|
||||
"""Start batch auto-tagging with streaming progress."""
|
||||
model_type = request.query.get("model_type", "gguf")
|
||||
custom_prompt = request.query.get("prompt", "")
|
||||
skip_tagged = request.query.get("skip_tagged", "true").lower() == "true"
|
||||
keep_in_memory = request.query.get("keep_in_memory", "true").lower() == "true"
|
||||
use_gpu = True
|
||||
|
||||
# WD14 threshold params
|
||||
general_threshold = request.query.get("general_threshold")
|
||||
character_threshold = request.query.get("character_threshold")
|
||||
try:
|
||||
if general_threshold is not None:
|
||||
general_threshold = float(general_threshold)
|
||||
if character_threshold is not None:
|
||||
character_threshold = float(character_threshold)
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "general_threshold and character_threshold must be numeric",
|
||||
},
|
||||
status=400,
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def stream_response():
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
import time as _time
|
||||
|
||||
service = get_autotag_service()
|
||||
|
||||
status = service.get_models_status()
|
||||
if not status.get(model_type, {}).get("downloaded"):
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'Model {model_type} not downloaded'})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': 'Loading model...'})}\n\n"
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: service.load_model(model_type, use_gpu)
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception("Failed to load model")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Failed to load model. Check server logs for details.'})}\n\n"
|
||||
return
|
||||
|
||||
if custom_prompt:
|
||||
service.custom_prompt = custom_prompt
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 5, 'status': 'Model loaded. Fetching all images from database...'})}\n\n"
|
||||
|
||||
images = await self._run_in_executor(self.db.get_all_images)
|
||||
|
||||
total_files = len(images)
|
||||
if total_files == 0:
|
||||
yield f"data: {json.dumps({'type': 'complete', 'processed': 0, 'tagged': 0, 'skipped': 0, 'status': 'No images with linked prompts found'})}\n\n"
|
||||
service.unload_model()
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 10, 'status': f'Found {total_files} images. Processing...'})}\n\n"
|
||||
|
||||
processed = 0
|
||||
tagged = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
tagged_prompt_ids = set()
|
||||
|
||||
last_update_time = _time.monotonic()
|
||||
|
||||
for i, image_data in enumerate(images):
|
||||
image_path = image_data.get("image_path")
|
||||
prompt_id = image_data.get("prompt_id")
|
||||
|
||||
if not image_path or not prompt_id:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if prompt_id in tagged_prompt_ids:
|
||||
skipped += 1
|
||||
now = _time.monotonic()
|
||||
if (now - last_update_time) >= 0.5 or i == total_files - 1:
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (prompt already processed)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = now
|
||||
continue
|
||||
|
||||
if not Path(image_path).exists():
|
||||
skipped += 1
|
||||
now = _time.monotonic()
|
||||
if (now - last_update_time) >= 0.5 or i == total_files - 1:
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (file missing)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = now
|
||||
continue
|
||||
|
||||
if skip_tagged:
|
||||
prompt_tags = image_data.get("prompt_tags", [])
|
||||
if isinstance(prompt_tags, str):
|
||||
prompt_tags = [
|
||||
t.strip() for t in prompt_tags.split(",") if t.strip()
|
||||
]
|
||||
real_tags = [
|
||||
t
|
||||
for t in prompt_tags
|
||||
if t != "auto-scanned"
|
||||
and not t.startswith("prepend:")
|
||||
and not t.startswith("append:")
|
||||
]
|
||||
if real_tags:
|
||||
tagged_prompt_ids.add(prompt_id)
|
||||
skipped += 1
|
||||
now = _time.monotonic()
|
||||
if (now - last_update_time) >= 0.5 or i == total_files - 1:
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (already tagged)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = now
|
||||
continue
|
||||
|
||||
try:
|
||||
tags = await loop.run_in_executor(
|
||||
None,
|
||||
lambda p=str(image_path): service.generate_tags(
|
||||
p,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
),
|
||||
)
|
||||
|
||||
processed += 1
|
||||
|
||||
if tags:
|
||||
existing_prompt = await self._run_in_executor(
|
||||
self.db.get_prompt_by_id, prompt_id
|
||||
)
|
||||
if existing_prompt:
|
||||
existing_tags = existing_prompt.get("tags", [])
|
||||
if isinstance(existing_tags, str):
|
||||
existing_tags = [
|
||||
t.strip()
|
||||
for t in existing_tags.split(",")
|
||||
if t.strip()
|
||||
]
|
||||
|
||||
new_tags = [t for t in tags if t not in existing_tags]
|
||||
if new_tags:
|
||||
all_tags = existing_tags + new_tags
|
||||
await self._run_in_executor(
|
||||
self.db.update_prompt_metadata,
|
||||
prompt_id,
|
||||
tags=all_tags,
|
||||
)
|
||||
tagged += 1
|
||||
else:
|
||||
skipped += 1
|
||||
else:
|
||||
skipped += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
tagged_prompt_ids.add(prompt_id)
|
||||
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = _time.monotonic()
|
||||
|
||||
except Exception as img_err:
|
||||
self.logger.error(f"Error processing {image_path}: {img_err}")
|
||||
errors += 1
|
||||
processed += 1
|
||||
|
||||
if not keep_in_memory:
|
||||
service.unload_model()
|
||||
model_status = "Model unloaded"
|
||||
else:
|
||||
model_status = "Model kept in memory"
|
||||
|
||||
yield f"data: {json.dumps({'type': 'complete', 'progress': 100, 'processed': processed, 'tagged': tagged, 'skipped': skipped, 'errors': errors, 'status': 'Complete', 'model_status': model_status, 'model_loaded': keep_in_memory})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"AutoTag error: {e}")
|
||||
import traceback
|
||||
|
||||
self.logger.exception("AutoTag error")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'An internal error occurred. Check server logs for details.'})}\n\n"
|
||||
async def _stream_sse(self, request, events):
|
||||
"""Write an async iterator of dict events as server-sent events.
|
||||
|
||||
A client disconnect raises ConnectionResetError from ``write``; the
|
||||
event generator is then closed so its cleanup runs before re-raising
|
||||
(aiohttp logs premature disconnects at debug level).
|
||||
"""
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
@@ -317,20 +124,409 @@ class AutotagRoutesMixin:
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
await response.prepare(request)
|
||||
|
||||
async for chunk in stream_response():
|
||||
await response.write(chunk.encode("utf-8"))
|
||||
|
||||
try:
|
||||
async for event in events:
|
||||
await response.write(f"data: {json.dumps(event)}\n\n".encode("utf-8"))
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
self.logger.info("Client disconnected; stopping autotag stream")
|
||||
await events.aclose()
|
||||
raise
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
async def download_autotag_model(self, request):
|
||||
"""Download an AutoTag model with streaming progress (POST only)."""
|
||||
model_type = request.match_info.get("model_type")
|
||||
|
||||
async def events():
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
if model_type not in service.models_config:
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": f"Invalid model type: {model_type}",
|
||||
}
|
||||
return
|
||||
|
||||
yield {
|
||||
"type": "progress",
|
||||
"progress": 0,
|
||||
"status": "Starting download...",
|
||||
}
|
||||
|
||||
def progress_callback(status: str, progress: float):
|
||||
pass
|
||||
|
||||
success = await self._run_in_executor(
|
||||
service.download_model, model_type, progress_callback
|
||||
)
|
||||
if success:
|
||||
yield {
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
"status": "Download complete",
|
||||
}
|
||||
else:
|
||||
yield {"type": "error", "message": "Download failed"}
|
||||
except Exception:
|
||||
self.logger.exception("Download model error")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": _INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
return await self._stream_sse(request, events())
|
||||
|
||||
async def _read_start_params(self, request) -> Optional[dict]:
|
||||
"""Merge query-string and optional JSON-body parameters for autotag/start.
|
||||
|
||||
Returns None when a body is present but is not a JSON object.
|
||||
"""
|
||||
params = dict(request.query)
|
||||
if request.can_read_body:
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
params.update(body)
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _real_tags(tags) -> list:
|
||||
"""Normalise a tag field to a list without bookkeeping markers."""
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
return [
|
||||
t
|
||||
for t in (tags or [])
|
||||
if t != "auto-scanned"
|
||||
and not t.startswith("prepend:")
|
||||
and not t.startswith("append:")
|
||||
]
|
||||
|
||||
async def _apply_tags_to_prompt(self, prompt_id, tags) -> bool:
|
||||
"""Merge ``tags`` into the prompt; True when something new was added."""
|
||||
existing_prompt = await self._run_in_executor(
|
||||
self.db.get_prompt_by_id, prompt_id
|
||||
)
|
||||
if not existing_prompt:
|
||||
return False
|
||||
existing_tags = existing_prompt.get("tags", [])
|
||||
if isinstance(existing_tags, str):
|
||||
existing_tags = [t.strip() for t in existing_tags.split(",") if t.strip()]
|
||||
new_tags = [t for t in tags if t not in existing_tags]
|
||||
if not new_tags:
|
||||
return False
|
||||
await self._run_in_executor(
|
||||
self.db.update_prompt_metadata, prompt_id, tags=existing_tags + new_tags
|
||||
)
|
||||
return True
|
||||
|
||||
async def _autotag_batch_events(self, service, opts: dict):
|
||||
"""Yield SSE progress dicts while tagging every image linked to a prompt."""
|
||||
import time as _time
|
||||
|
||||
model_type = opts["model_type"]
|
||||
keep_in_memory = opts["keep_in_memory"]
|
||||
status = service.get_models_status()
|
||||
if not status.get(model_type, {}).get("downloaded"):
|
||||
yield {"type": "error", "message": f"Model {model_type} not downloaded"}
|
||||
return
|
||||
|
||||
yield {"type": "progress", "progress": 0, "status": "Loading model..."}
|
||||
try:
|
||||
await self._run_in_executor(service.load_model, model_type, True)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to load model")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": "Failed to load model. Check server logs for details.",
|
||||
}
|
||||
return
|
||||
|
||||
if opts["custom_prompt"]:
|
||||
service.custom_prompt = opts["custom_prompt"]
|
||||
|
||||
yield {
|
||||
"type": "progress",
|
||||
"progress": 5,
|
||||
"status": "Model loaded. Fetching all images from database...",
|
||||
}
|
||||
images = await self._run_in_executor(self.db.get_all_images)
|
||||
total = len(images)
|
||||
if total == 0:
|
||||
yield {
|
||||
"type": "complete",
|
||||
"processed": 0,
|
||||
"tagged": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"skipped_outside_roots": 0,
|
||||
"status": "No images with linked prompts found",
|
||||
}
|
||||
service.unload_model()
|
||||
return
|
||||
|
||||
yield {
|
||||
"type": "progress",
|
||||
"progress": 10,
|
||||
"status": f"Found {total} images. Processing...",
|
||||
}
|
||||
|
||||
counts = {
|
||||
"processed": 0,
|
||||
"tagged": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"skipped_outside_roots": 0,
|
||||
}
|
||||
tagged_prompt_ids = set()
|
||||
last_update = _time.monotonic()
|
||||
finished = False
|
||||
|
||||
def progress_event(i, message):
|
||||
return {
|
||||
"type": "progress",
|
||||
"progress": 10 + int((i + 1) / total * 85),
|
||||
"status": message,
|
||||
"processed": counts["processed"],
|
||||
"tagged": counts["tagged"],
|
||||
"skipped": counts["skipped"],
|
||||
"skipped_outside_roots": counts["skipped_outside_roots"],
|
||||
}
|
||||
|
||||
try:
|
||||
for i, image_data in enumerate(images):
|
||||
image_path = image_data.get("image_path")
|
||||
prompt_id = image_data.get("prompt_id")
|
||||
skip_reason = None
|
||||
allowed_path = None
|
||||
if not image_path or not prompt_id:
|
||||
skip_reason = "unlinked"
|
||||
elif prompt_id in tagged_prompt_ids:
|
||||
skip_reason = "prompt already processed"
|
||||
else:
|
||||
# Rows can point anywhere (restored DB, hand edits): only
|
||||
# media files inside the served output roots are tagged.
|
||||
allowed_path = self._resolve_allowed_image_path(str(image_path))
|
||||
if allowed_path is None:
|
||||
if _file_exists(image_path):
|
||||
skip_reason = "outside allowed directories"
|
||||
counts["skipped_outside_roots"] += 1
|
||||
else:
|
||||
skip_reason = "file missing"
|
||||
elif opts["skip_tagged"] and self._real_tags(
|
||||
image_data.get("prompt_tags", [])
|
||||
):
|
||||
tagged_prompt_ids.add(prompt_id)
|
||||
skip_reason = "already tagged"
|
||||
|
||||
if skip_reason is not None:
|
||||
counts["skipped"] += 1
|
||||
now = _time.monotonic()
|
||||
if (now - last_update) >= 0.5 or i == total - 1:
|
||||
yield progress_event(
|
||||
i, f"Skipping {i + 1}/{total} ({skip_reason})..."
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
last_update = now
|
||||
continue
|
||||
|
||||
try:
|
||||
tags = await self._run_in_executor(
|
||||
service.generate_tags,
|
||||
allowed_path,
|
||||
general_threshold=opts["general_threshold"],
|
||||
character_threshold=opts["character_threshold"],
|
||||
)
|
||||
counts["processed"] += 1
|
||||
if tags and await self._apply_tags_to_prompt(prompt_id, tags):
|
||||
counts["tagged"] += 1
|
||||
else:
|
||||
counts["skipped"] += 1
|
||||
tagged_prompt_ids.add(prompt_id)
|
||||
except Exception as img_err:
|
||||
self.logger.error(
|
||||
f"Error processing {os.path.basename(str(image_path))}: "
|
||||
f"{img_err}"
|
||||
)
|
||||
counts["errors"] += 1
|
||||
counts["processed"] += 1
|
||||
|
||||
yield progress_event(i, f"Processing {i + 1}/{total}...")
|
||||
await asyncio.sleep(0.01)
|
||||
last_update = _time.monotonic()
|
||||
|
||||
finished = True
|
||||
if not keep_in_memory:
|
||||
service.unload_model()
|
||||
yield {
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
**counts,
|
||||
"status": "Complete",
|
||||
"model_status": (
|
||||
"Model kept in memory" if keep_in_memory else "Model unloaded"
|
||||
),
|
||||
"model_loaded": keep_in_memory,
|
||||
}
|
||||
finally:
|
||||
if not finished and not keep_in_memory:
|
||||
service.unload_model()
|
||||
|
||||
async def start_autotag(self, request):
|
||||
"""Start batch auto-tagging with streaming progress (POST only)."""
|
||||
params = await self._read_start_params(request)
|
||||
if params is None:
|
||||
return self._bad_request("Request body must be a JSON object")
|
||||
|
||||
opts = {
|
||||
"model_type": params.get("model_type", "gguf"),
|
||||
"custom_prompt": params.get("prompt", ""),
|
||||
"skip_tagged": str(params.get("skip_tagged", "true")).lower() == "true",
|
||||
"keep_in_memory": str(params.get("keep_in_memory", "true")).lower()
|
||||
== "true",
|
||||
"general_threshold": params.get("general_threshold"),
|
||||
"character_threshold": params.get("character_threshold"),
|
||||
}
|
||||
try:
|
||||
for key in ("general_threshold", "character_threshold"):
|
||||
if opts[key] is not None:
|
||||
opts[key] = float(opts[key])
|
||||
except (ValueError, TypeError):
|
||||
return self._bad_request(
|
||||
"general_threshold and character_threshold must be numeric"
|
||||
)
|
||||
|
||||
async def events():
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
async for event in self._autotag_batch_events(
|
||||
get_autotag_service(), opts
|
||||
):
|
||||
yield event
|
||||
except Exception:
|
||||
self.logger.exception("AutoTag error")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": _INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
if not _autotag_batch_lock.acquire(blocking=False):
|
||||
return web.json_response(
|
||||
{"success": False, "error": _BUSY_ERROR}, status=409
|
||||
)
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
finally:
|
||||
_autotag_batch_lock.release()
|
||||
|
||||
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
return None
|
||||
for output_dir in self._get_all_output_dirs():
|
||||
if path_is_within(raw_path, output_dir):
|
||||
real = os.path.realpath(raw_path)
|
||||
return real if os.path.isfile(real) else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _forbidden(raw_path: str):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
f"{os.path.basename(str(raw_path).rstrip('/' + os.sep))} is not "
|
||||
"an image inside the configured output directories"
|
||||
),
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
async def _select_autotag_target(self, data: dict):
|
||||
"""Pick the image to tag from an autotag/single body.
|
||||
|
||||
Returns ``(image_path, prompt_id, None)`` on success or
|
||||
``(None, None, error_response)`` when the request must be rejected.
|
||||
"""
|
||||
image_id = data.get("image_id")
|
||||
if image_id is not None:
|
||||
if isinstance(image_id, bool) or not isinstance(image_id, (int, str)):
|
||||
return None, None, self._bad_request("image_id must be an integer")
|
||||
try:
|
||||
image_id = int(image_id)
|
||||
except (TypeError, ValueError):
|
||||
return None, None, self._bad_request("image_id must be an integer")
|
||||
record = await self._run_in_executor(self.db.get_image_by_id, image_id)
|
||||
if not record or not record.get("image_path"):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
web.json_response(
|
||||
{"success": False, "error": f"Image {image_id} not found"},
|
||||
status=404,
|
||||
),
|
||||
)
|
||||
stored_path = record["image_path"]
|
||||
resolved = self._resolve_allowed_image_path(stored_path)
|
||||
if resolved is None:
|
||||
return None, None, self._forbidden(stored_path)
|
||||
return resolved, record.get("prompt_id"), None
|
||||
|
||||
raw_path = data.get("path") or data.get("image_path")
|
||||
if not raw_path or not isinstance(raw_path, str):
|
||||
return None, None, self._bad_request("image_id or path is required")
|
||||
|
||||
if "root_index" in data:
|
||||
root_index = data["root_index"]
|
||||
if isinstance(root_index, bool) or not isinstance(root_index, int):
|
||||
return None, None, self._bad_request("root_index must be an integer")
|
||||
roots = self._get_all_output_dirs()
|
||||
if not 0 <= root_index < len(roots):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
root = roots[root_index]
|
||||
if Path(raw_path).is_absolute() or ".." in raw_path.replace(
|
||||
"\\", "/"
|
||||
).split("/"):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
raw_path = str(Path(root) / raw_path)
|
||||
if not path_is_within(raw_path, root):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
resolved = self._resolve_allowed_image_path(raw_path)
|
||||
if resolved is None:
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
prompt_id = None
|
||||
try:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.get_prompt_id_for_image, raw_path
|
||||
)
|
||||
if prompt_id is None and resolved != raw_path:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.get_prompt_id_for_image, resolved
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not find linked prompt: {e}")
|
||||
return resolved, prompt_id, None
|
||||
|
||||
@staticmethod
|
||||
def _bad_request(message: str):
|
||||
return web.json_response({"success": False, "error": message}, status=400)
|
||||
|
||||
async def autotag_single(self, request):
|
||||
"""Generate tags for a single image."""
|
||||
"""Generate tags for a single image selected by image_id or gallery path."""
|
||||
try:
|
||||
data = await request.json()
|
||||
image_path = data.get("image_path")
|
||||
if not isinstance(data, dict):
|
||||
return self._bad_request("Request body must be a JSON object")
|
||||
model_type = data.get("model_type", "gguf")
|
||||
custom_prompt = data.get("prompt")
|
||||
use_gpu = data.get("use_gpu", True)
|
||||
@@ -342,18 +538,13 @@ class AutotagRoutesMixin:
|
||||
if character_threshold is not None:
|
||||
character_threshold = float(character_threshold)
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "general_threshold and character_threshold must be numeric",
|
||||
},
|
||||
status=400,
|
||||
return self._bad_request(
|
||||
"general_threshold and character_threshold must be numeric"
|
||||
)
|
||||
|
||||
if not image_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "image_path is required"}, status=400
|
||||
)
|
||||
image_path, prompt_id, error = await self._select_autotag_target(data)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
@@ -363,44 +554,34 @@ class AutotagRoutesMixin:
|
||||
not service.is_model_loaded()
|
||||
or service.get_loaded_model_type() != model_type
|
||||
):
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None, lambda: service.load_model(model_type, use_gpu)
|
||||
)
|
||||
await self._run_in_executor(service.load_model, model_type, use_gpu)
|
||||
|
||||
if custom_prompt:
|
||||
service.custom_prompt = custom_prompt
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
tags = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: service.generate_tags(
|
||||
image_path,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
),
|
||||
tags = await self._run_in_executor(
|
||||
service.generate_tags,
|
||||
image_path,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
)
|
||||
|
||||
prompt_id = None
|
||||
try:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.get_prompt_id_for_image, image_path
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not find linked prompt: {e}")
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"tags": tags,
|
||||
"prompt_id": prompt_id,
|
||||
"image_path": image_path,
|
||||
"filename": os.path.basename(image_path),
|
||||
}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return self._bad_request("Request body must be valid JSON")
|
||||
except Exception as e:
|
||||
self.logger.error(f"AutoTag single error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def apply_autotag(self, request):
|
||||
"""Apply selected tags to a prompt."""
|
||||
@@ -445,7 +626,9 @@ class AutotagRoutesMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Apply autotag error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def unload_autotag_model(self, request):
|
||||
"""Manually unload the AutoTag model from memory."""
|
||||
@@ -472,7 +655,37 @@ class AutotagRoutesMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unload autotag model error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_scan_files(output_path: Path):
|
||||
"""Media files under ``output_path`` from a bounded, symlink-free walk.
|
||||
|
||||
Descends at most ``_SCAN_MAX_DEPTH`` levels, skips ``thumbnails``
|
||||
trees and symlinked files, and stops after ``_SCAN_MAX_FILES`` files.
|
||||
Returns ``(paths, truncated)``.
|
||||
"""
|
||||
found = []
|
||||
base_depth = len(output_path.parts)
|
||||
for dirpath, dirnames, filenames in os.walk(output_path):
|
||||
depth = len(Path(dirpath).parts) - base_depth
|
||||
dirnames[:] = sorted(
|
||||
name
|
||||
for name in dirnames
|
||||
if name != "thumbnails" and depth + 1 <= _SCAN_MAX_DEPTH
|
||||
)
|
||||
for name in sorted(filenames):
|
||||
if Path(name).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
continue
|
||||
candidate = Path(dirpath) / name
|
||||
if candidate.is_symlink():
|
||||
continue
|
||||
if len(found) >= _SCAN_MAX_FILES:
|
||||
return found, True
|
||||
found.append(candidate)
|
||||
return found, False
|
||||
|
||||
async def scan_output_dir(self, request):
|
||||
"""Scan ComfyUI output directory for images."""
|
||||
@@ -485,63 +698,49 @@ class AutotagRoutesMixin:
|
||||
)
|
||||
|
||||
output_path = Path(output_dir)
|
||||
image_extensions = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".tiff",
|
||||
]
|
||||
files, truncated = await self._run_in_executor(
|
||||
self._collect_scan_files, output_path
|
||||
)
|
||||
has_thumbnails = (output_path / "thumbnails").is_dir()
|
||||
|
||||
images = []
|
||||
seen_paths = set()
|
||||
|
||||
for ext in image_extensions:
|
||||
for pattern in [f"*{ext.lower()}", f"*{ext.upper()}"]:
|
||||
for image_path in output_path.rglob(pattern):
|
||||
if "thumbnails" not in image_path.parts:
|
||||
normalized_path = str(image_path).lower()
|
||||
if normalized_path not in seen_paths:
|
||||
seen_paths.add(normalized_path)
|
||||
|
||||
rel_path = image_path.relative_to(output_path)
|
||||
|
||||
thumbnail_url = None
|
||||
thumbnails_dir = output_path / "thumbnails"
|
||||
if thumbnails_dir.exists():
|
||||
rel_path_no_ext = rel_path.with_suffix("")
|
||||
thumbnail_rel_path = f"thumbnails/{rel_path_no_ext.as_posix()}_thumb{image_path.suffix}"
|
||||
thumbnail_abs_path = (
|
||||
thumbnails_dir
|
||||
/ f"{rel_path_no_ext.as_posix()}_thumb{image_path.suffix}"
|
||||
)
|
||||
if thumbnail_abs_path.exists():
|
||||
from urllib.parse import quote
|
||||
|
||||
thumbnail_url = f'/prompt_manager/images/serve/{quote(thumbnail_rel_path, safe="/")}'
|
||||
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
images.append(
|
||||
{
|
||||
"filename": image_path.name,
|
||||
"path": str(image_path),
|
||||
"relative_path": str(rel_path),
|
||||
"url": f'/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe="/")}',
|
||||
"thumbnail_url": thumbnail_url,
|
||||
}
|
||||
)
|
||||
for image_path in files:
|
||||
rel_path = image_path.relative_to(output_path)
|
||||
thumbnail_url = None
|
||||
if has_thumbnails:
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}"
|
||||
f"_thumb{image_path.suffix}"
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": image_path.name,
|
||||
"path": self._public_path(image_path),
|
||||
"relative_path": rel_path.as_posix(),
|
||||
"url": "/prompt_manager/images/serve/"
|
||||
+ quote(rel_path.as_posix(), safe="/"),
|
||||
"thumbnail_url": thumbnail_url,
|
||||
}
|
||||
)
|
||||
|
||||
images.sort(key=lambda x: x["filename"])
|
||||
|
||||
self.logger.info(f"Found {len(images)} images in output directory")
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "images": images, "count": len(images)}
|
||||
{
|
||||
"success": True,
|
||||
"images": images,
|
||||
"count": len(images),
|
||||
"truncated": truncated,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Scan output dir error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
@@ -4,6 +4,86 @@ import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# Keys PromptManagerLogger.update_config() understands. Anything else is
|
||||
# rejected so a client cannot inject arbitrary entries into the logging config.
|
||||
LOG_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"level",
|
||||
"max_file_size",
|
||||
"backup_count",
|
||||
"console_logging",
|
||||
"file_logging",
|
||||
"buffer_size",
|
||||
}
|
||||
)
|
||||
_LOG_INT_KEYS = frozenset({"max_file_size", "backup_count", "buffer_size"})
|
||||
_LOG_BOOL_KEYS = frozenset({"console_logging", "file_logging"})
|
||||
_LOG_LEVEL_NAMES = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
|
||||
# Inclusive bounds for the integer keys that size the log files on disk
|
||||
_MEGABYTE = 1024 * 1024
|
||||
_LOG_INT_BOUNDS = {
|
||||
"max_file_size": (1 * _MEGABYTE, 1024 * _MEGABYTE),
|
||||
"backup_count": (0, 50),
|
||||
}
|
||||
_LOG_INT_BOUND_LABELS = {
|
||||
"max_file_size": "between 1 MB and 1024 MB (in bytes)",
|
||||
"backup_count": "between 0 and 50",
|
||||
}
|
||||
|
||||
DEFAULT_LOG_LIMIT = 100
|
||||
MAX_LOG_LIMIT = 500
|
||||
|
||||
|
||||
def _parse_limit(raw):
|
||||
"""Parse a ``limit`` query value, clamped to [1, MAX_LOG_LIMIT].
|
||||
|
||||
Raises:
|
||||
ValueError: when ``raw`` is not an integer.
|
||||
"""
|
||||
if raw is None or raw == "":
|
||||
return DEFAULT_LOG_LIMIT
|
||||
limit = int(raw)
|
||||
return max(1, min(limit, MAX_LOG_LIMIT))
|
||||
|
||||
|
||||
def _validate_log_config(data):
|
||||
"""Validate a log-config payload against LOG_CONFIG_KEYS.
|
||||
|
||||
Returns:
|
||||
(clean, error): ``clean`` is the normalised dict to apply, ``error``
|
||||
a message describing the first problem (``clean`` is then ``None``).
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None, "Request body must be a JSON object"
|
||||
|
||||
unknown = sorted(key for key in data if key not in LOG_CONFIG_KEYS)
|
||||
if unknown:
|
||||
return None, (
|
||||
f"Unknown logging config keys: {', '.join(unknown)}. "
|
||||
f"Allowed: {', '.join(sorted(LOG_CONFIG_KEYS))}"
|
||||
)
|
||||
|
||||
clean = {}
|
||||
for key, value in data.items():
|
||||
if key == "level":
|
||||
if not isinstance(value, str) or value.upper() not in _LOG_LEVEL_NAMES:
|
||||
return None, (
|
||||
f"Invalid log level. Must be one of: {sorted(_LOG_LEVEL_NAMES)}"
|
||||
)
|
||||
clean[key] = value.upper()
|
||||
elif key in _LOG_INT_KEYS:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
return None, f"{key} must be a non-negative integer"
|
||||
bounds = _LOG_INT_BOUNDS.get(key)
|
||||
if bounds and not bounds[0] <= value <= bounds[1]:
|
||||
return None, f"{key} must be {_LOG_INT_BOUND_LABELS[key]}"
|
||||
clean[key] = value
|
||||
elif key in _LOG_BOOL_KEYS:
|
||||
if not isinstance(value, bool):
|
||||
return None, f"{key} must be a boolean"
|
||||
clean[key] = value
|
||||
return clean, None
|
||||
|
||||
|
||||
class LoggingRoutesMixin:
|
||||
"""Mixin providing logging-related API endpoints."""
|
||||
@@ -56,14 +136,15 @@ class LoggingRoutesMixin:
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
|
||||
limit = int(request.query.get("limit", 100))
|
||||
try:
|
||||
limit = _parse_limit(request.query.get("limit"))
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "limit must be an integer", "logs": []},
|
||||
status=400,
|
||||
)
|
||||
level = request.query.get("level", None)
|
||||
|
||||
if limit > 1000:
|
||||
limit = 1000
|
||||
elif limit < 1:
|
||||
limit = 1
|
||||
|
||||
logs = logger_manager.get_recent_logs(limit=limit, level=level)
|
||||
|
||||
return web.json_response(
|
||||
@@ -79,14 +160,18 @@ class LoggingRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get logs error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e), "logs": []}, status=500
|
||||
{"success": False, "error": self._public_error(e), "logs": []},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_log_files(self, request):
|
||||
"""Get information about available log files."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
log_files = logger_manager.get_log_files()
|
||||
log_files = [
|
||||
{**entry, "path": self._public_path(entry.get("path"))}
|
||||
for entry in logger_manager.get_log_files()
|
||||
]
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "files": log_files, "count": len(log_files)}
|
||||
@@ -95,7 +180,8 @@ class LoggingRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get log files error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e), "files": []}, status=500
|
||||
{"success": False, "error": self._public_error(e), "files": []},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def download_log_file(self, request):
|
||||
@@ -130,7 +216,9 @@ class LoggingRoutesMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Download log file error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def truncate_logs(self, request):
|
||||
"""Truncate all log files."""
|
||||
@@ -148,7 +236,9 @@ class LoggingRoutesMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Truncate logs error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def get_log_config(self, request):
|
||||
"""Get current logging configuration."""
|
||||
@@ -160,27 +250,23 @@ class LoggingRoutesMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get log config error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def update_log_config(self, request):
|
||||
"""Update logging configuration."""
|
||||
"""Update logging configuration (whitelisted keys only)."""
|
||||
try:
|
||||
data = await request.json()
|
||||
data, error_response = await self._read_json_body(request)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
clean, error = _validate_log_config(data)
|
||||
if error:
|
||||
return web.json_response({"success": False, "error": error}, status=400)
|
||||
|
||||
logger_manager = self._get_logger_manager()
|
||||
|
||||
if "level" in data:
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if data["level"].upper() not in valid_levels:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Invalid log level. Must be one of: {valid_levels}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
data["level"] = data["level"].upper()
|
||||
|
||||
logger_manager.update_config(data)
|
||||
logger_manager.update_config(clean)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -192,16 +278,22 @@ class LoggingRoutesMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update log config error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Failed to update logging configuration"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_log_stats(self, request):
|
||||
"""Get logging statistics."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
stats = logger_manager.get_log_stats()
|
||||
stats = dict(logger_manager.get_log_stats())
|
||||
stats["log_directory"] = self._public_path(stats.get("log_directory"))
|
||||
|
||||
return web.json_response({"success": True, "stats": stats})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get log stats error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""LoraManager integration API routes for PromptManager."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_LORA_PATH_ERROR = (
|
||||
"LoraManager path must be a directory under ComfyUI's custom_nodes "
|
||||
"whose name contains 'lora'"
|
||||
)
|
||||
|
||||
|
||||
class LoraIntegrationMixin:
|
||||
"""Mixin providing LoraManager detection, scanning, and trigger word endpoints."""
|
||||
|
||||
def _register_lora_routes(self, routes):
|
||||
@routes.get("/prompt_manager/lora/detect")
|
||||
async def lora_detect_route(request):
|
||||
return await self.lora_detect(request)
|
||||
|
||||
@routes.get("/prompt_manager/lora/status")
|
||||
async def lora_status_route(request):
|
||||
return await self.lora_status(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/enable")
|
||||
async def lora_enable_route(request):
|
||||
return await self.lora_enable(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/scan")
|
||||
async def lora_scan_route(request):
|
||||
return await self.lora_scan(request)
|
||||
|
||||
@routes.get("/prompt_manager/lora/trigger-words")
|
||||
async def lora_trigger_words_route(request):
|
||||
return await self.lora_trigger_words(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/refresh-cache")
|
||||
async def lora_refresh_cache_route(request):
|
||||
return await self.lora_refresh_cache(request)
|
||||
|
||||
def _error_response(self, exc: Exception, status: int = 500):
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(exc)}, status=status
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bad_lora_request(message: str):
|
||||
return web.json_response({"success": False, "error": message}, status=400)
|
||||
|
||||
# ── Detection ────────────────────────────────────────────────────
|
||||
|
||||
async def lora_detect(self, request):
|
||||
"""Auto-detect LoraManager installation."""
|
||||
try:
|
||||
from ..lora_utils import detect_lora_manager
|
||||
|
||||
path = await self._run_in_executor(detect_lora_manager)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"detected": path is not None,
|
||||
"path": self._public_path(path) if path else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager detection failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────
|
||||
|
||||
async def lora_status(self, request):
|
||||
"""Get current LoraManager integration status."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import detect_lora_manager, get_trigger_cache
|
||||
|
||||
config = IntegrationConfig.get_config()["lora_manager"]
|
||||
cache = get_trigger_cache()
|
||||
|
||||
# Check if the configured path is still valid
|
||||
detected_path = await self._run_in_executor(
|
||||
detect_lora_manager, config.get("path", "")
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"enabled": config["enabled"],
|
||||
"path": self._public_path(config["path"]),
|
||||
"trigger_words_enabled": config["trigger_words_enabled"],
|
||||
"has_civitai_api_key": bool(config.get("civitai_api_key")),
|
||||
"detected": detected_path is not None,
|
||||
"detected_path": (
|
||||
self._public_path(detected_path) if detected_path else ""
|
||||
),
|
||||
"trigger_cache_loaded": cache.is_loaded,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager status check failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
# ── Enable / Disable ─────────────────────────────────────────────
|
||||
|
||||
async def _validated_lora_manager_path(self, path):
|
||||
"""Canonical LoraManager path for ``path`` or ``(None, error_response)``.
|
||||
|
||||
The user's path (or, when empty, the auto-detected install) must be a
|
||||
LoraManager-looking directory that is a direct child of ComfyUI's
|
||||
``custom_nodes`` with ``lora`` in its name; the trees under it are
|
||||
later served as images, so nothing outside custom_nodes may be used.
|
||||
"""
|
||||
from ..lora_utils import detect_lora_manager, resolve_lora_manager_path
|
||||
|
||||
candidate = ""
|
||||
if path:
|
||||
candidate = await self._run_in_executor(resolve_lora_manager_path, path)
|
||||
if candidate is None:
|
||||
return None, self._bad_lora_request(_LORA_PATH_ERROR)
|
||||
|
||||
resolved = await self._run_in_executor(detect_lora_manager, candidate)
|
||||
if not resolved:
|
||||
return None, self._bad_lora_request(
|
||||
"LoraManager not found at the specified path"
|
||||
)
|
||||
|
||||
allowed = await self._run_in_executor(resolve_lora_manager_path, resolved)
|
||||
if allowed is None:
|
||||
return None, self._bad_lora_request(_LORA_PATH_ERROR)
|
||||
return allowed, None
|
||||
|
||||
async def lora_enable(self, request):
|
||||
"""Enable or disable LoraManager integration and save to config.json."""
|
||||
try:
|
||||
data = await request.json()
|
||||
enabled = data.get("enabled", False)
|
||||
path = data.get("path", "")
|
||||
trigger_words = data.get("trigger_words_enabled", False)
|
||||
|
||||
from ..config import IntegrationConfig, PromptManagerConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
# The key is never echoed back to the UI, so a blank or absent
|
||||
# field means "unchanged"; clearing is an explicit request.
|
||||
civitai_key = IntegrationConfig.CIVITAI_API_KEY
|
||||
submitted_key = data.get("civitai_api_key")
|
||||
if submitted_key is not None and not isinstance(submitted_key, str):
|
||||
return self._bad_lora_request("civitai_api_key must be a string")
|
||||
if data.get("clear_civitai_api_key"):
|
||||
civitai_key = ""
|
||||
elif submitted_key and submitted_key.strip():
|
||||
civitai_key = submitted_key.strip()
|
||||
|
||||
if not isinstance(path, str):
|
||||
return self._bad_lora_request("path must be a string")
|
||||
|
||||
# If enabling, validate the path
|
||||
if enabled:
|
||||
path, error = await self._validated_lora_manager_path(path)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
# Update in-memory config
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = enabled
|
||||
IntegrationConfig.LORA_MANAGER_PATH = path
|
||||
IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = trigger_words
|
||||
IntegrationConfig.CIVITAI_API_KEY = civitai_key
|
||||
|
||||
# Persist to the configured config.json
|
||||
PromptManagerConfig.save_to_file()
|
||||
|
||||
# Load trigger word cache if enabling
|
||||
cache = get_trigger_cache()
|
||||
if enabled and trigger_words and path:
|
||||
count = await self._run_in_executor(cache.load, path)
|
||||
self.logger.info(f"Trigger word cache loaded: {count} LoRAs")
|
||||
elif not enabled:
|
||||
cache.clear()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"enabled": enabled,
|
||||
"path": self._public_path(path),
|
||||
"trigger_words_enabled": trigger_words,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager enable/disable failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
# ── Scan LoRA example images ─────────────────────────────────────
|
||||
|
||||
async def lora_scan(self, request):
|
||||
"""Scan LoraManager metadata and import LoRA info + preview images.
|
||||
|
||||
Streams progress as SSE, matching the existing scan pattern. Errors
|
||||
raised once the stream is open are reported as an ``error`` event.
|
||||
"""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import resolve_lora_manager_path
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return self._bad_lora_request("LoraManager integration is not enabled")
|
||||
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if not lm_path:
|
||||
return self._bad_lora_request("LoraManager path not configured")
|
||||
|
||||
allowed = await self._run_in_executor(resolve_lora_manager_path, lm_path)
|
||||
if allowed is None:
|
||||
return self._bad_lora_request(_LORA_PATH_ERROR)
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
async def send_progress(data):
|
||||
line = f"data: {json.dumps(data)}\n\n"
|
||||
await response.write(line.encode("utf-8"))
|
||||
|
||||
try:
|
||||
await self._lora_scan_stream(allowed, send_progress)
|
||||
except Exception as e:
|
||||
self.logger.exception("LoRA scan failed")
|
||||
await send_progress({"type": "error", "message": self._public_error(e)})
|
||||
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoRA scan failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
async def _lora_scan_stream(self, lm_path, send_progress):
|
||||
"""Import every LoRA under ``lm_path``, reporting through ``send_progress``."""
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import (
|
||||
download_civitai_images,
|
||||
find_lora_directories,
|
||||
get_example_prompt_from_metadata,
|
||||
get_lora_image_cache_dir,
|
||||
get_preview_images_from_metadata,
|
||||
get_trigger_words_from_metadata,
|
||||
get_model_name_from_metadata,
|
||||
read_lora_metadata,
|
||||
)
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": "Clearing previous lora-manager imports...",
|
||||
"progress": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Clear previous imports so reimport is always clean
|
||||
await self._run_in_executor(self.db.delete_prompts_by_category, "lora-manager")
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": "Finding LoRA directories...",
|
||||
"progress": 2,
|
||||
}
|
||||
)
|
||||
|
||||
lora_dirs = await self._run_in_executor(find_lora_directories, lm_path)
|
||||
|
||||
# Collect all metadata files
|
||||
meta_files = []
|
||||
for d in lora_dirs:
|
||||
dir_path = Path(d)
|
||||
meta_files.extend(dir_path.rglob("*.metadata.json"))
|
||||
|
||||
total = len(meta_files)
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": f"Found {total} LoRA metadata files",
|
||||
"progress": 5,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
cache_dir = get_lora_image_cache_dir()
|
||||
|
||||
for i, meta_file in enumerate(meta_files):
|
||||
metadata = await self._run_in_executor(read_lora_metadata, meta_file)
|
||||
if not metadata:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
model_name = get_model_name_from_metadata(metadata)
|
||||
trigger_words = get_trigger_words_from_metadata(metadata)
|
||||
|
||||
# Collect all images: local previews + downloaded civitai examples
|
||||
preview_paths = await self._run_in_executor(
|
||||
get_preview_images_from_metadata, metadata, meta_file
|
||||
)
|
||||
civitai_paths = await self._run_in_executor(
|
||||
download_civitai_images,
|
||||
metadata,
|
||||
meta_file,
|
||||
cache_dir,
|
||||
IntegrationConfig.CIVITAI_API_KEY,
|
||||
)
|
||||
|
||||
# Merge, local first, dedup
|
||||
seen = set(preview_paths)
|
||||
all_images = list(preview_paths)
|
||||
for cp in civitai_paths:
|
||||
if cp not in seen:
|
||||
all_images.append(cp)
|
||||
seen.add(cp)
|
||||
|
||||
# Build prompt text: prefer example prompt, then model name
|
||||
example_prompt = get_example_prompt_from_metadata(metadata)
|
||||
prompt_text = example_prompt or model_name
|
||||
|
||||
# Build tags
|
||||
tags = ["lora-manager", f"lora:{model_name}"]
|
||||
tags.extend(trigger_words)
|
||||
|
||||
# Save to database via existing mechanism
|
||||
try:
|
||||
import hashlib
|
||||
|
||||
prompt_hash = hashlib.sha256(
|
||||
prompt_text.strip().lower().encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
existing = await self._run_in_executor(
|
||||
self.db.get_prompt_by_hash, prompt_hash
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Link all images
|
||||
for pp in all_images:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
existing["id"],
|
||||
pp,
|
||||
)
|
||||
skipped += 1
|
||||
else:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.save_prompt,
|
||||
prompt_text,
|
||||
"lora-manager", # category
|
||||
tags,
|
||||
None, # rating
|
||||
None, # notes
|
||||
prompt_hash,
|
||||
)
|
||||
|
||||
if prompt_id:
|
||||
for pp in all_images:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
prompt_id,
|
||||
pp,
|
||||
)
|
||||
imported += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to import LoRA {model_name}: {e}")
|
||||
skipped += 1
|
||||
|
||||
# Progress update for every LoRA
|
||||
progress = int(5 + (90 * (i + 1) / max(total, 1)))
|
||||
img_count = len(all_images)
|
||||
status = f"{model_name}"
|
||||
if img_count:
|
||||
status += f" ({img_count} images)"
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"processed": i + 1,
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
}
|
||||
)
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
"total": total,
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
}
|
||||
)
|
||||
|
||||
# ── Trigger word endpoints ───────────────────────────────────────
|
||||
|
||||
async def lora_trigger_words(self, request):
|
||||
"""Look up trigger words for a specific LoRA name."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return self._bad_lora_request("LoraManager integration not enabled")
|
||||
|
||||
lora_name = request.query.get("name", "")
|
||||
if not lora_name:
|
||||
return self._bad_lora_request("Missing 'name' query parameter")
|
||||
|
||||
cache = get_trigger_cache()
|
||||
if not cache.is_loaded:
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if lm_path:
|
||||
await self._run_in_executor(cache.load, lm_path)
|
||||
|
||||
words = cache.get_trigger_words(lora_name)
|
||||
return web.json_response(
|
||||
{"success": True, "lora": lora_name, "trigger_words": words}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Trigger word lookup failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
async def lora_refresh_cache(self, request):
|
||||
"""Force-refresh the trigger word cache from disk."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return self._bad_lora_request("LoraManager integration not enabled")
|
||||
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if not lm_path:
|
||||
return self._bad_lora_request("LoraManager path not configured")
|
||||
|
||||
cache = get_trigger_cache()
|
||||
count = await self._run_in_executor(cache.load, lm_path)
|
||||
return web.json_response(
|
||||
{"success": True, "loras_with_trigger_words": count}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Trigger cache refresh failed: {e}")
|
||||
return self._error_response(e)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Reusable ordered prompt combinations, separate from legacy prompt CRUD."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .prompts import parse_page_params
|
||||
|
||||
try:
|
||||
from ...database.pairs import PairDependencyError
|
||||
from ...utils.validators import (
|
||||
validate_category,
|
||||
validate_prompt_text,
|
||||
validate_rating,
|
||||
validate_tags,
|
||||
)
|
||||
except ImportError:
|
||||
from database.pairs import PairDependencyError
|
||||
from utils.validators import (
|
||||
validate_category,
|
||||
validate_prompt_text,
|
||||
validate_rating,
|
||||
validate_tags,
|
||||
)
|
||||
|
||||
|
||||
def pair_response(handler: Callable) -> Callable:
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception:
|
||||
self.logger.exception("Prompt pair operation failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Prompt pair operation failed"}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def metadata_fields(data: dict) -> dict:
|
||||
fields = {}
|
||||
for name in ("title", "category", "tags", "rating", "notes"):
|
||||
if name not in data:
|
||||
continue
|
||||
value = data[name]
|
||||
if name == "tags":
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Tags must be a list of strings")
|
||||
validate_tags(value)
|
||||
elif name == "rating":
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("Rating must be an integer")
|
||||
validate_rating(value)
|
||||
else:
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError(f"{name} must be a string")
|
||||
if name == "category":
|
||||
validate_category(value)
|
||||
elif value is not None and len(value) > 10000:
|
||||
raise ValueError(f"{name} is too long")
|
||||
fields[name] = value
|
||||
return fields
|
||||
|
||||
|
||||
async def object_body(request: web.Request) -> dict:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def missing_pair() -> web.Response:
|
||||
return web.json_response({"success": False, "error": "Pair not found"}, status=404)
|
||||
|
||||
|
||||
class PairRoutesMixin:
|
||||
def _register_pair_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/pairs/export")(self.export_prompt_pairs)
|
||||
routes.get("/prompt_manager/pairs")(self.list_prompt_pairs)
|
||||
routes.post("/prompt_manager/pairs")(self.save_prompt_pair)
|
||||
routes.get("/prompt_manager/pairs/{pair_id}")(self.get_prompt_pair)
|
||||
routes.patch("/prompt_manager/pairs/{pair_id}")(self.update_prompt_pair)
|
||||
routes.delete("/prompt_manager/pairs/{pair_id}")(self.delete_prompt_pair)
|
||||
|
||||
@pair_response
|
||||
async def save_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
data = await object_body(request)
|
||||
positive = data.get("positive_text")
|
||||
negative = data.get("negative_text")
|
||||
validate_prompt_text(positive)
|
||||
if not isinstance(negative, str):
|
||||
raise ValueError(
|
||||
"negative_text must be explicit; use an empty string for none"
|
||||
)
|
||||
if negative.strip():
|
||||
validate_prompt_text(negative)
|
||||
fields = metadata_fields(data)
|
||||
pair_id = await self._run_in_executor(
|
||||
self.db.save_prompt_pair, positive, negative, **fields
|
||||
)
|
||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||
return web.json_response({"success": True, "pair": pair})
|
||||
|
||||
@pair_response
|
||||
async def get_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
pair = await self._run_in_executor(
|
||||
self.db.get_prompt_pair, int(request.match_info["pair_id"])
|
||||
)
|
||||
return (
|
||||
web.json_response({"success": True, "pair": pair})
|
||||
if pair
|
||||
else missing_pair()
|
||||
)
|
||||
|
||||
@pair_response
|
||||
async def list_prompt_pairs(self, request: web.Request) -> web.Response:
|
||||
limit, offset = parse_page_params(request.query)
|
||||
archived = request.query.get("include_archived", "false")
|
||||
if archived not in ("true", "false"):
|
||||
raise ValueError("include_archived must be true or false")
|
||||
result = await self._run_in_executor(
|
||||
self.db.list_prompt_pairs,
|
||||
text=request.query.get("text"),
|
||||
category=request.query.get("category"),
|
||||
tags=request.query.getall("tag", []),
|
||||
include_archived=archived == "true",
|
||||
sort=request.query.get("sort", "newest"),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@pair_response
|
||||
async def update_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
pair_id = int(request.match_info["pair_id"])
|
||||
data = await object_body(request)
|
||||
allowed = {"title", "category", "tags", "rating", "notes", "archived"}
|
||||
if not data or set(data) - allowed:
|
||||
raise ValueError(
|
||||
"Update metadata or archive; save a new pair to change text"
|
||||
)
|
||||
fields = metadata_fields(data)
|
||||
if "archived" in data:
|
||||
if not isinstance(data["archived"], bool) or fields:
|
||||
raise ValueError("Archive must be a separate boolean update")
|
||||
updated = await self._run_in_executor(
|
||||
self.db.archive_prompt_pair, pair_id, data["archived"]
|
||||
)
|
||||
else:
|
||||
updated = await self._run_in_executor(
|
||||
self.db.update_prompt_pair_metadata, pair_id, **fields
|
||||
)
|
||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||
if not updated and not pair:
|
||||
return missing_pair()
|
||||
return web.json_response({"success": True, "pair": pair})
|
||||
|
||||
@pair_response
|
||||
async def delete_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
deleted = await self._run_in_executor(
|
||||
self.db.delete_prompt_pair, int(request.match_info["pair_id"])
|
||||
)
|
||||
return web.json_response({"success": True}) if deleted else missing_pair()
|
||||
|
||||
@pair_response
|
||||
async def export_prompt_pairs(self, request: web.Request) -> web.Response:
|
||||
pairs = []
|
||||
offset = 0
|
||||
while True:
|
||||
result = await self._run_in_executor(
|
||||
self.db.list_prompt_pairs,
|
||||
include_archived=True,
|
||||
limit=500,
|
||||
offset=offset,
|
||||
)
|
||||
for pair in result["pairs"]:
|
||||
pairs.append(
|
||||
{
|
||||
key: pair[key]
|
||||
for key in (
|
||||
"positive_text",
|
||||
"negative_text",
|
||||
"title",
|
||||
"category",
|
||||
"tags",
|
||||
"rating",
|
||||
"notes",
|
||||
"archived",
|
||||
)
|
||||
}
|
||||
)
|
||||
offset += len(result["pairs"])
|
||||
if not result["has_more"] or not result["pairs"]:
|
||||
break
|
||||
return web.json_response(
|
||||
{"schema_version": 1, "kind": "prompt-manager-pairs", "pairs": pairs},
|
||||
headers={"Content-Disposition": 'attachment; filename="prompt-pairs.json"'},
|
||||
)
|
||||
@@ -2,9 +2,99 @@
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.pairs import PairDependencyError
|
||||
except ImportError:
|
||||
from database.pairs import PairDependencyError
|
||||
from urllib.parse import quote
|
||||
|
||||
# Upper bounds on page sizes and offsets for every list endpoint. The offset
|
||||
# ceiling keeps values inside SQLite's 64-bit range and bounds skip work.
|
||||
MAX_PAGE_LIMIT = 500
|
||||
MAX_PAGE_OFFSET = 10_000_000
|
||||
|
||||
# The export walks the whole table in pages of this size instead of asking
|
||||
# for one giant result set (and silently truncating large libraries).
|
||||
EXPORT_PAGE_SIZE = 1000
|
||||
|
||||
|
||||
def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT):
|
||||
"""Return ``(limit, offset)`` from a query mapping, clamped to safe bounds.
|
||||
|
||||
``limit`` is clamped to ``[1, max_limit]`` and ``offset`` to
|
||||
``[0, MAX_PAGE_OFFSET]``. Non-integer values raise ``ValueError`` so the
|
||||
caller can answer 400.
|
||||
"""
|
||||
limit = int(query.get("limit", default_limit))
|
||||
offset = int(query.get("offset", 0))
|
||||
return max(1, min(limit, max_limit)), max(0, min(offset, MAX_PAGE_OFFSET))
|
||||
|
||||
|
||||
def bad_request(message):
|
||||
return web.json_response({"success": False, "error": message}, status=400)
|
||||
|
||||
|
||||
def tags_error(tags):
|
||||
"""A 400 response when *tags* fail ``validate_tags``, else None."""
|
||||
try:
|
||||
validate_tags(tags)
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
return None
|
||||
|
||||
|
||||
def safe_error_message(exc):
|
||||
"""Describe *exc* for a client without leaking absolute server paths.
|
||||
|
||||
OSError carries the offending path in ``filename``; only its basename
|
||||
is echoed back together with ``strerror``.
|
||||
"""
|
||||
if isinstance(exc, OSError):
|
||||
reason = exc.strerror or exc.__class__.__name__
|
||||
if exc.filename:
|
||||
return f"{reason}: {os.path.basename(str(exc.filename))}"
|
||||
return reason
|
||||
return str(exc) or exc.__class__.__name__
|
||||
|
||||
|
||||
def thumbnail_url_for(thumb_rel, thumb_abs):
|
||||
"""Serve URL for an existing thumbnail, or None when the file is missing.
|
||||
|
||||
The URL carries the file's mtime as ``?v=``: thumbnails are served with a
|
||||
long cache lifetime, so a regenerated file must get a new URL or browsers
|
||||
keep showing whatever they cached for the old one.
|
||||
"""
|
||||
try:
|
||||
version = int(thumb_abs.stat().st_mtime)
|
||||
except OSError:
|
||||
return None
|
||||
return f"/prompt_manager/images/serve/{quote(thumb_rel, safe='/')}?v={version}"
|
||||
|
||||
|
||||
def publish_image_paths(images, public_path):
|
||||
"""Image dicts for a response: ``image_path`` in its public (ComfyUI
|
||||
relative) form and the absolute ``file_path`` duplicate removed.
|
||||
|
||||
``url``/``thumbnail_url`` added by enrichment are left untouched, so the
|
||||
frontend keeps working without ever seeing the server's directory layout.
|
||||
"""
|
||||
published = []
|
||||
for image in images:
|
||||
if not isinstance(image, dict):
|
||||
published.append(image)
|
||||
continue
|
||||
image = {k: v for k, v in image.items() if k != "file_path"}
|
||||
if "image_path" in image:
|
||||
image["image_path"] = public_path(image["image_path"])
|
||||
published.append(image)
|
||||
return published
|
||||
|
||||
|
||||
try:
|
||||
from ...utils.validators import (
|
||||
validate_prompt_text,
|
||||
@@ -50,6 +140,10 @@ class PromptRoutesMixin:
|
||||
async def get_tags_filter_route(request):
|
||||
return await self.get_tags_filter(request)
|
||||
|
||||
@routes.get("/prompt_manager/tags/suggest")
|
||||
async def suggest_tags_route(request):
|
||||
return await self.suggest_tags(request)
|
||||
|
||||
# Bulk tag operations (register BEFORE {tag_name} to avoid path param match)
|
||||
@routes.post("/prompt_manager/tags/merge")
|
||||
async def merge_tags_route(request):
|
||||
@@ -122,6 +216,15 @@ class PromptRoutesMixin:
|
||||
async def export_prompts_route(request):
|
||||
return await self.export_prompts(request)
|
||||
|
||||
def _present_prompts(self, prompts):
|
||||
"""Prompts ready for a response: image urls added, server paths hidden."""
|
||||
self._enrich_prompt_images(prompts)
|
||||
for prompt in prompts:
|
||||
prompt["images"] = publish_image_paths(
|
||||
prompt.get("images", []), self._public_path
|
||||
)
|
||||
return prompts
|
||||
|
||||
async def search_prompts(self, request):
|
||||
"""Search for prompts using multiple filter criteria."""
|
||||
try:
|
||||
@@ -129,9 +232,14 @@ class PromptRoutesMixin:
|
||||
category = request.query.get("category", "").strip()
|
||||
tags_str = request.query.get("tags", "").strip()
|
||||
min_rating = request.query.get("min_rating", 0)
|
||||
limit = int(request.query.get("limit", 50))
|
||||
try:
|
||||
limit, offset = parse_page_params(request.query)
|
||||
except ValueError:
|
||||
return bad_request("limit and offset must be integers")
|
||||
|
||||
folder = request.query.get("folder", "").strip() or None
|
||||
# Validated against a whitelist in the database layer
|
||||
sort = request.query.get("sort") or None
|
||||
|
||||
tags = None
|
||||
if tags_str:
|
||||
@@ -149,18 +257,33 @@ class PromptRoutesMixin:
|
||||
tags=tags,
|
||||
rating_min=min_rating,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
folder=folder,
|
||||
sort=sort,
|
||||
)
|
||||
self._enrich_prompt_images(results)
|
||||
self._present_prompts(results)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "results": results, "count": len(results)}
|
||||
{
|
||||
"success": True,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"pagination": {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"count": len(results),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Search error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Search failed: {str(e)}", "results": []},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Search failed: {safe_error_message(e)}",
|
||||
"results": [],
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -170,33 +293,38 @@ class PromptRoutesMixin:
|
||||
from ..config import GalleryConfig
|
||||
|
||||
root_dirs = list(GalleryConfig.MONITORING_DIRECTORIES) or None
|
||||
include_ancestors = (
|
||||
request.query.get("include_ancestors", "").lower() == "true"
|
||||
)
|
||||
subfolders = await self._run_in_executor(
|
||||
self.db.get_prompt_subfolders, root_dirs
|
||||
self.db.get_prompt_subfolders, root_dirs, include_ancestors
|
||||
)
|
||||
return web.json_response({"success": True, "subfolders": subfolders})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Subfolders error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": safe_error_message(e)}, status=500
|
||||
)
|
||||
|
||||
async def get_recent_prompts(self, request):
|
||||
"""Retrieve recently created prompts with pagination support."""
|
||||
"""Retrieve prompts with pagination and an optional sort (default newest
|
||||
first)."""
|
||||
try:
|
||||
limit = int(request.query.get("limit", 50))
|
||||
page = int(request.query.get("page", 1))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
try:
|
||||
limit, offset = parse_page_params(request.query)
|
||||
page = int(request.query.get("page", 1))
|
||||
except ValueError:
|
||||
return bad_request("limit, offset and page must be integers")
|
||||
|
||||
if page > 1 and offset == 0:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
if limit > 1000:
|
||||
limit = 1000
|
||||
elif limit < 1:
|
||||
limit = 1
|
||||
offset = min((page - 1) * limit, MAX_PAGE_OFFSET)
|
||||
|
||||
# Validated against a whitelist in the database layer
|
||||
sort = request.query.get("sort") or None
|
||||
results = await self._run_in_executor(
|
||||
self.db.get_recent_prompts, limit=limit, offset=offset
|
||||
self.db.get_recent_prompts, limit=limit, offset=offset, sort=sort
|
||||
)
|
||||
self._enrich_prompt_images(results["prompts"])
|
||||
self._present_prompts(results["prompts"])
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -219,7 +347,7 @@ class PromptRoutesMixin:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to get recent prompts: {str(e)}",
|
||||
"error": f"Failed to get recent prompts: {safe_error_message(e)}",
|
||||
"results": [],
|
||||
"pagination": {"total": 0, "page": 1, "total_pages": 0},
|
||||
},
|
||||
@@ -236,7 +364,7 @@ class PromptRoutesMixin:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to get categories: {str(e)}",
|
||||
"error": f"Failed to get categories: {safe_error_message(e)}",
|
||||
"categories": [],
|
||||
},
|
||||
status=500,
|
||||
@@ -252,7 +380,7 @@ class PromptRoutesMixin:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to get tags: {str(e)}",
|
||||
"error": f"Failed to get tags: {safe_error_message(e)}",
|
||||
"tags": [],
|
||||
},
|
||||
status=500,
|
||||
@@ -262,13 +390,9 @@ class PromptRoutesMixin:
|
||||
"""Get tags with usage counts, search, sort, and pagination."""
|
||||
try:
|
||||
try:
|
||||
limit = int(request.query.get("limit", 50))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid limit or offset parameter"},
|
||||
status=400,
|
||||
)
|
||||
limit, offset = parse_page_params(request.query)
|
||||
except ValueError:
|
||||
return bad_request("limit and offset must be integers")
|
||||
search = request.query.get("search", "").strip() or None
|
||||
sort = request.query.get("sort", "alpha_asc")
|
||||
|
||||
@@ -294,32 +418,30 @@ class PromptRoutesMixin:
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Tags stats error: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": safe_error_message(e)}, status=500
|
||||
)
|
||||
|
||||
async def get_tag_prompts(self, request):
|
||||
"""Get prompts for a single tag."""
|
||||
try:
|
||||
from urllib.parse import unquote
|
||||
|
||||
tag_name = unquote(request.match_info.get("tag_name", ""))
|
||||
# aiohttp has already percent-decoded match_info; decoding again
|
||||
# would turn a tag literally named "%41" into "A".
|
||||
tag_name = request.match_info.get("tag_name", "")
|
||||
if not tag_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Tag name required"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid limit or offset parameter"},
|
||||
status=400,
|
||||
)
|
||||
limit, offset = parse_page_params(request.query, default_limit=20)
|
||||
except ValueError:
|
||||
return bad_request("limit and offset must be integers")
|
||||
|
||||
result = await self._run_in_executor(
|
||||
self.db.get_prompts_by_tags, [tag_name], "and", limit, offset
|
||||
)
|
||||
self._enrich_prompt_images(result["prompts"])
|
||||
self._present_prompts(result["prompts"])
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -336,7 +458,46 @@ class PromptRoutesMixin:
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Tag prompts error: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": safe_error_message(e)}, status=500
|
||||
)
|
||||
|
||||
async def suggest_tags(self, request):
|
||||
"""Autocomplete for the tag search box.
|
||||
|
||||
``q`` is the prefix being typed, ``with`` the comma-separated tags
|
||||
already entered: only tags that co-occur with all of them are offered.
|
||||
"""
|
||||
try:
|
||||
prefix = request.query.get("q", "").strip()[:100]
|
||||
context = []
|
||||
seen = set()
|
||||
for raw in request.query.get("with", "").split(","):
|
||||
name = raw.strip()
|
||||
if name and name.lower() not in seen:
|
||||
seen.add(name.lower())
|
||||
context.append(name)
|
||||
context = context[:10]
|
||||
try:
|
||||
limit = int(request.query.get("limit", 15))
|
||||
except ValueError:
|
||||
limit = 15
|
||||
limit = max(1, min(limit, 50))
|
||||
|
||||
suggestions = await self._run_in_executor(
|
||||
self.db.suggest_tags, prefix, context, limit
|
||||
)
|
||||
return web.json_response({"success": True, "suggestions": suggestions})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Tag suggest error: {e}")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to suggest tags",
|
||||
"suggestions": [],
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_tags_filter(self, request):
|
||||
"""Get prompts matching multiple tags with AND/OR mode, or untagged prompts."""
|
||||
@@ -345,20 +506,13 @@ class PromptRoutesMixin:
|
||||
|
||||
if untagged:
|
||||
try:
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Invalid limit or offset parameter",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
limit, offset = parse_page_params(request.query, default_limit=20)
|
||||
except ValueError:
|
||||
return bad_request("limit and offset must be integers")
|
||||
result = await self._run_in_executor(
|
||||
self.db.get_untagged_prompts, limit, offset
|
||||
)
|
||||
self._enrich_prompt_images(result["prompts"])
|
||||
self._present_prompts(result["prompts"])
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
@@ -386,18 +540,14 @@ class PromptRoutesMixin:
|
||||
mode = "and"
|
||||
|
||||
try:
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid limit or offset parameter"},
|
||||
status=400,
|
||||
)
|
||||
limit, offset = parse_page_params(request.query, default_limit=20)
|
||||
except ValueError:
|
||||
return bad_request("limit and offset must be integers")
|
||||
|
||||
result = await self._run_in_executor(
|
||||
self.db.get_prompts_by_tags, tags_list, mode, limit, offset
|
||||
)
|
||||
self._enrich_prompt_images(result["prompts"])
|
||||
self._present_prompts(result["prompts"])
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -415,14 +565,16 @@ class PromptRoutesMixin:
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Tags filter error: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": safe_error_message(e)}, status=500
|
||||
)
|
||||
|
||||
async def rename_tag_endpoint(self, request):
|
||||
"""Rename a tag across all prompts."""
|
||||
try:
|
||||
from urllib.parse import unquote
|
||||
|
||||
tag_name = unquote(request.match_info.get("tag_name", ""))
|
||||
# aiohttp has already percent-decoded match_info; decoding again
|
||||
# would turn a tag literally named "%41" into "A".
|
||||
tag_name = request.match_info.get("tag_name", "")
|
||||
if not tag_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Tag name required"}, status=400
|
||||
@@ -434,11 +586,15 @@ class PromptRoutesMixin:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
new_name = body.get("new_name", "").strip()
|
||||
if not new_name:
|
||||
raw_name = body.get("new_name")
|
||||
if not raw_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "New tag name required"}, status=400
|
||||
)
|
||||
error = tags_error([raw_name])
|
||||
if error is not None:
|
||||
return error
|
||||
new_name = raw_name.strip()
|
||||
|
||||
result = await self._run_in_executor(
|
||||
self.db.rename_tag_all_prompts, tag_name, new_name
|
||||
@@ -452,19 +608,22 @@ class PromptRoutesMixin:
|
||||
if result.get("skipped_count", 0) > 0:
|
||||
resp["skipped_count"] = result["skipped_count"]
|
||||
resp["warning"] = (
|
||||
f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped"
|
||||
f"{result['skipped_count']} prompt(s) had corrupted tag data "
|
||||
"and were skipped"
|
||||
)
|
||||
return web.json_response(resp)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Rename tag error: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": safe_error_message(e)}, status=500
|
||||
)
|
||||
|
||||
async def delete_tag_endpoint(self, request):
|
||||
"""Delete a tag from all prompts."""
|
||||
try:
|
||||
from urllib.parse import unquote
|
||||
|
||||
tag_name = unquote(request.match_info.get("tag_name", ""))
|
||||
# aiohttp has already percent-decoded match_info; decoding again
|
||||
# would turn a tag literally named "%41" into "A".
|
||||
tag_name = request.match_info.get("tag_name", "")
|
||||
if not tag_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Tag name required"}, status=400
|
||||
@@ -481,12 +640,15 @@ class PromptRoutesMixin:
|
||||
if result.get("skipped_count", 0) > 0:
|
||||
resp["skipped_count"] = result["skipped_count"]
|
||||
resp["warning"] = (
|
||||
f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped"
|
||||
f"{result['skipped_count']} prompt(s) had corrupted tag data "
|
||||
"and were skipped"
|
||||
)
|
||||
return web.json_response(resp)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Delete tag error: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": safe_error_message(e)}, status=500
|
||||
)
|
||||
|
||||
async def merge_tags_endpoint(self, request):
|
||||
"""Merge source tags into a target tag."""
|
||||
@@ -498,16 +660,22 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
source_tags = body.get("source_tags", [])
|
||||
target_tag = body.get("target_tag", "").strip()
|
||||
raw_target = body.get("target_tag")
|
||||
|
||||
if not source_tags:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Source tags required"}, status=400
|
||||
)
|
||||
if not target_tag:
|
||||
if not raw_target:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Target tag required"}, status=400
|
||||
)
|
||||
if not isinstance(source_tags, list):
|
||||
return bad_request("Source tags must be a list")
|
||||
error = tags_error([*source_tags, raw_target])
|
||||
if error is not None:
|
||||
return error
|
||||
target_tag = raw_target.strip()
|
||||
|
||||
result = await self._run_in_executor(
|
||||
self.db.merge_tags, source_tags, target_tag
|
||||
@@ -521,28 +689,31 @@ class PromptRoutesMixin:
|
||||
if result.get("skipped_count", 0) > 0:
|
||||
resp["skipped_count"] = result["skipped_count"]
|
||||
resp["warning"] = (
|
||||
f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped"
|
||||
f"{result['skipped_count']} prompt(s) had corrupted tag data "
|
||||
"and were skipped"
|
||||
)
|
||||
return web.json_response(resp)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Merge tags error: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
return web.json_response(
|
||||
{"success": False, "error": safe_error_message(e)}, status=500
|
||||
)
|
||||
|
||||
async def save_prompt(self, request):
|
||||
"""Save a new prompt with metadata and duplicate detection."""
|
||||
try:
|
||||
data = await request.json()
|
||||
|
||||
text = data.get("text", "").strip()
|
||||
text = (data.get("text") or "").strip()
|
||||
if not text:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Text is required"}, status=400
|
||||
)
|
||||
|
||||
category = data.get("category", "").strip() or None
|
||||
category = (data.get("category") or "").strip() or None
|
||||
tags = data.get("tags", [])
|
||||
rating = data.get("rating") or None
|
||||
notes = data.get("notes", "").strip() or None
|
||||
notes = (data.get("notes") or "").strip() or None
|
||||
|
||||
try:
|
||||
validate_prompt_text(text)
|
||||
@@ -601,7 +772,10 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Save error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to save prompt: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to save prompt: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -624,6 +798,8 @@ class PromptRoutesMixin:
|
||||
status=404,
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid prompt ID"}, status=400
|
||||
@@ -631,7 +807,10 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Delete error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to delete prompt: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to delete prompt: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -640,7 +819,7 @@ class PromptRoutesMixin:
|
||||
try:
|
||||
prompt_id = int(request.match_info["prompt_id"])
|
||||
data = await request.json()
|
||||
new_text = data.get("text", "").strip()
|
||||
new_text = (data.get("text") or "").strip()
|
||||
|
||||
if not new_text:
|
||||
return web.json_response(
|
||||
@@ -668,6 +847,13 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "Prompt not found"}, status=404
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except sqlite3.IntegrityError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Another prompt already has this text"},
|
||||
status=409,
|
||||
)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid prompt ID"}, status=400
|
||||
@@ -675,7 +861,10 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update prompt error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to update prompt: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to update prompt: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -712,7 +901,10 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update rating error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to update rating: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to update rating: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -721,12 +913,16 @@ class PromptRoutesMixin:
|
||||
try:
|
||||
prompt_id = int(request.match_info["prompt_id"])
|
||||
data = await request.json()
|
||||
new_tag = data.get("tag", "").strip()
|
||||
raw_tag = data.get("tag")
|
||||
|
||||
if not new_tag:
|
||||
if not raw_tag:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Tag cannot be empty"}, status=400
|
||||
)
|
||||
error = tags_error([raw_tag])
|
||||
if error is not None:
|
||||
return error
|
||||
new_tag = raw_tag.strip()
|
||||
|
||||
prompt = await self._run_in_executor(self.db.get_prompt_by_id, prompt_id)
|
||||
if not prompt:
|
||||
@@ -755,7 +951,11 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Add tag error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to add tag: {str(e)}"}, status=500
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to add tag: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def add_tags_to_prompt(self, request):
|
||||
@@ -775,6 +975,12 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "Tags must be a non-empty list"},
|
||||
status=400,
|
||||
)
|
||||
# Blank entries are skipped below, so only the rest is validated.
|
||||
error = tags_error(
|
||||
[t for t in new_tags if not (isinstance(t, str) and not t.strip())]
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
prompt = await self._run_in_executor(self.db.get_prompt_by_id, prompt_id)
|
||||
if not prompt:
|
||||
@@ -813,7 +1019,11 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Add tags error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to add tags: {str(e)}"}, status=500
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to add tags: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def remove_prompt_tag(self, request):
|
||||
@@ -821,7 +1031,7 @@ class PromptRoutesMixin:
|
||||
try:
|
||||
prompt_id = int(request.match_info["prompt_id"])
|
||||
data = await request.json()
|
||||
tag_to_remove = data.get("tag", "").strip()
|
||||
tag_to_remove = (data.get("tag") or "").strip()
|
||||
|
||||
prompt = await self._run_in_executor(self.db.get_prompt_by_id, prompt_id)
|
||||
if not prompt:
|
||||
@@ -850,7 +1060,10 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Remove tag error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to remove tag: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to remove tag: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -877,10 +1090,15 @@ class PromptRoutesMixin:
|
||||
}
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk delete error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to delete prompts: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to delete prompts: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -896,6 +1114,11 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "No prompt IDs or tags provided"},
|
||||
status=400,
|
||||
)
|
||||
if not isinstance(new_tags, list):
|
||||
return bad_request("Tags must be a list")
|
||||
error = tags_error(new_tags)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
updated_count = await self._run_in_executor(
|
||||
self.db.bulk_add_tags, prompt_ids, new_tags
|
||||
@@ -912,7 +1135,11 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk add tags error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to add tags: {str(e)}"}, status=500
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to add tags: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def bulk_set_category(self, request):
|
||||
@@ -920,7 +1147,7 @@ class PromptRoutesMixin:
|
||||
try:
|
||||
data = await request.json()
|
||||
prompt_ids = data.get("prompt_ids", [])
|
||||
category = data.get("category", "").strip()
|
||||
category = (data.get("category") or "").strip()
|
||||
|
||||
if not prompt_ids:
|
||||
return web.json_response(
|
||||
@@ -942,14 +1169,27 @@ class PromptRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk set category error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to set category: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to set category: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
def _all_prompts_for_export(self):
|
||||
"""Every prompt, fetched in EXPORT_PAGE_SIZE pages (blocking)."""
|
||||
prompts, offset = [], 0
|
||||
while True:
|
||||
page = self.db.search_prompts(limit=EXPORT_PAGE_SIZE, offset=offset)
|
||||
prompts.extend(page)
|
||||
if len(page) < EXPORT_PAGE_SIZE:
|
||||
return prompts
|
||||
offset += EXPORT_PAGE_SIZE
|
||||
|
||||
async def export_prompts(self, request):
|
||||
"""Export all prompts to JSON."""
|
||||
try:
|
||||
prompts = await self._run_in_executor(self.db.search_prompts, limit=10000)
|
||||
prompts = await self._run_in_executor(self._all_prompts_for_export)
|
||||
|
||||
export_data = {
|
||||
"export_date": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
@@ -963,13 +1203,19 @@ class PromptRoutesMixin:
|
||||
text=json_data,
|
||||
content_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="prompt_manager_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.json"'
|
||||
"Content-Disposition": (
|
||||
'attachment; filename="prompt_manager_'
|
||||
f'{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.json"'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Export error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Failed to export prompts: {str(e)}"},
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to export prompts: {safe_error_message(e)}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Progress fan-out for the background output scan.
|
||||
|
||||
The scan runs as an asyncio task that outlives the HTTP request that started
|
||||
it, so an accidental tab close does not abandon a half-finished scan. Every
|
||||
SSE subscriber (the request that started the scan, a reconnecting page, a
|
||||
second tab) receives the latest event as a snapshot and then every event
|
||||
published after it, until the job finishes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class ScanJob:
|
||||
"""One scan run: its latest event, completion flag and live subscribers."""
|
||||
|
||||
def __init__(self):
|
||||
self.last_event = None
|
||||
self.finished = False
|
||||
self.cancelled = False
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return not self.finished
|
||||
|
||||
@property
|
||||
def subscriber_count(self):
|
||||
return len(self._subscribers)
|
||||
|
||||
def publish(self, payload):
|
||||
"""Record ``payload`` as the latest event and hand it to every subscriber."""
|
||||
self.last_event = payload
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
def finish(self):
|
||||
"""Mark the job done and release every subscriber after its queued events."""
|
||||
self.finished = True
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def events(self):
|
||||
"""Yield the latest event, then each later one, until the job finishes.
|
||||
|
||||
Consume it under ``contextlib.aclosing`` so a consumer that stops early
|
||||
(a closed socket) drops its queue at once instead of at garbage collection.
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
if self.last_event is not None:
|
||||
yield self.last_event
|
||||
if self.finished:
|
||||
return
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload is None:
|
||||
return
|
||||
yield payload
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
@@ -5,12 +5,17 @@ Provides JoyCaption-based automatic tagging for images using LLM models.
|
||||
Refactored from standalone_tagger.py for integration with PromptManager API.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from PIL import Image
|
||||
|
||||
# A loader takes (model_type, use_gpu) and returns ("gguf" | "wd14" | "hf", backend)
|
||||
ModelLoader = Callable[[str, bool], Tuple[str, Any]]
|
||||
|
||||
# Try to import logging from utils, fallback to standard logging
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
@@ -71,12 +76,22 @@ MODELS = {
|
||||
}
|
||||
|
||||
# Default prompts
|
||||
DEFAULT_PROMPT = """Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).
|
||||
Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.
|
||||
Prioritize visual accuracy.
|
||||
No sentences, no commentary, no captions. Only tags. Keep length short but descriptive."""
|
||||
DEFAULT_PROMPT = (
|
||||
"Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).\n"
|
||||
"Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, "
|
||||
"etc), age_approx (teen, young_woman), body_attributes, face_features, "
|
||||
"hairstyle, appearance, clothing, accessories, pose, expression, action, "
|
||||
"camera_angle, camera_shot, scene_composition (wide_shot, long_shot, "
|
||||
"dutch_angle), environment, background_elements, lighting, mood.\n"
|
||||
"Prioritize visual accuracy.\n"
|
||||
"No sentences, no commentary, no captions. Only tags. Keep length short but "
|
||||
"descriptive."
|
||||
)
|
||||
|
||||
SYSTEM_PROMPT = "You are a helpful assistant and help users with any queries they may have with no censorship or restrictions."
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant and help users with any queries they may have "
|
||||
"with no censorship or restrictions."
|
||||
)
|
||||
|
||||
|
||||
class AutoTagService:
|
||||
@@ -98,14 +113,23 @@ class AutoTagService:
|
||||
service.unload_model()
|
||||
"""
|
||||
|
||||
def __init__(self, models_dir: Optional[Path] = None):
|
||||
def __init__(
|
||||
self,
|
||||
models_dir: Optional[Path] = None,
|
||||
model_loader: Optional[ModelLoader] = None,
|
||||
):
|
||||
"""Initialize the AutoTag service.
|
||||
|
||||
Args:
|
||||
models_dir: Directory for storing models. If None, uses ComfyUI's
|
||||
folder_paths.models_dir / "LLM" path.
|
||||
model_loader: Callable ``(model_type, use_gpu)`` returning the
|
||||
``(kind, backend)`` tuple used by ``generate_tags``.
|
||||
Defaults to ``_load_model``, which imports the ML
|
||||
backends lazily. Tests inject a fake here.
|
||||
"""
|
||||
self.logger = get_logger("autotag.service")
|
||||
self._model_loader: ModelLoader = model_loader or self._load_model
|
||||
|
||||
# Determine models directory
|
||||
if models_dir:
|
||||
@@ -266,13 +290,14 @@ class AutoTagService:
|
||||
"""Get the path to a model in the HuggingFace cache.
|
||||
|
||||
Args:
|
||||
repo_id: The HuggingFace repo ID (e.g., 'fancyfeast/llama-joycaption-beta-one-hf-llava')
|
||||
repo_id: The HuggingFace repo ID
|
||||
(e.g., 'fancyfeast/llama-joycaption-beta-one-hf-llava')
|
||||
|
||||
Returns:
|
||||
Path to the cached model directory, or None if not found
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir, HFCacheInfo
|
||||
from huggingface_hub import scan_cache_dir
|
||||
except ImportError:
|
||||
self.logger.debug("huggingface_hub not available for cache check")
|
||||
return None
|
||||
@@ -466,11 +491,15 @@ class AutoTagService:
|
||||
"""
|
||||
if model_type not in MODELS:
|
||||
raise ValueError(
|
||||
f"Invalid model type: {model_type}. Must be one of: {', '.join(MODELS.keys())}"
|
||||
f"Invalid model type: {model_type}. "
|
||||
f"Must be one of: {', '.join(MODELS.keys())}"
|
||||
)
|
||||
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download, snapshot_download
|
||||
from huggingface_hub import ( # noqa: F401 - availability probe
|
||||
hf_hub_download,
|
||||
snapshot_download,
|
||||
)
|
||||
except ImportError:
|
||||
self.logger.error("huggingface_hub not installed")
|
||||
if progress_callback:
|
||||
@@ -591,13 +620,7 @@ class AutoTagService:
|
||||
raise RuntimeError(f"Model {model_type} not downloaded")
|
||||
|
||||
try:
|
||||
if model_type == "gguf":
|
||||
self._tagger = self._load_gguf_tagger(use_gpu)
|
||||
elif model_type.startswith("wd14"):
|
||||
self._tagger = self._load_wd14_tagger(model_type, use_gpu)
|
||||
else:
|
||||
self._tagger = self._load_hf_tagger()
|
||||
|
||||
self._tagger = self._model_loader(model_type, use_gpu)
|
||||
self._current_model_type = model_type
|
||||
self.logger.info(f"Model {model_type} loaded successfully")
|
||||
return True
|
||||
@@ -608,6 +631,14 @@ class AutoTagService:
|
||||
self._current_model_type = None
|
||||
raise RuntimeError(f"Failed to load model: {e}")
|
||||
|
||||
def _load_model(self, model_type: str, use_gpu: bool = True) -> Tuple[str, Any]:
|
||||
"""Default loader: dispatch to a backend, importing it only now."""
|
||||
if model_type == "gguf":
|
||||
return self._load_gguf_tagger(use_gpu)
|
||||
if model_type.startswith("wd14"):
|
||||
return self._load_wd14_tagger(model_type, use_gpu)
|
||||
return self._load_hf_tagger()
|
||||
|
||||
def _load_gguf_tagger(self, use_gpu: bool = True):
|
||||
"""Load GGUF-based tagger."""
|
||||
from llama_cpp import Llama
|
||||
@@ -690,14 +721,11 @@ class AutoTagService:
|
||||
self._current_model_type = None
|
||||
gc.collect()
|
||||
|
||||
# Try to clear CUDA cache if available
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
# Clear the CUDA cache only if torch is already loaded; importing
|
||||
# it here would pull in a heavy dependency the backend never used.
|
||||
torch = sys.modules.get("torch")
|
||||
if torch is not None and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
self.logger.info("Model unloaded, memory freed")
|
||||
|
||||
@@ -720,7 +748,8 @@ class AutoTagService:
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file
|
||||
prompt: Custom prompt for tag generation (LLM models only). Uses default if None.
|
||||
prompt: Custom prompt for tag generation (LLM models only).
|
||||
Uses default if None.
|
||||
general_threshold: Confidence threshold for general tags (WD14 only).
|
||||
character_threshold: Confidence threshold for character tags (WD14 only).
|
||||
|
||||
@@ -735,7 +764,7 @@ class AutoTagService:
|
||||
raise RuntimeError("No model loaded. Call load_model() first.")
|
||||
|
||||
if not os.path.exists(image_path):
|
||||
raise FileNotFoundError(f"Image not found: {image_path}")
|
||||
raise FileNotFoundError(errno.ENOENT, "Image not found", str(image_path))
|
||||
|
||||
use_prompt = prompt or self._custom_prompt
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Example:
|
||||
extension_name = "PromptManager"
|
||||
|
||||
# Get server instance and routes (same pattern as ComfyUI_Assets)
|
||||
from server import PromptServer
|
||||
from server import PromptServer # noqa: E402
|
||||
|
||||
server_instance = PromptServer.instance
|
||||
routes = server_instance.routes
|
||||
@@ -30,8 +30,9 @@ routes = server_instance.routes
|
||||
# Extension info
|
||||
extension_uri = None # Will be set in __init__.py
|
||||
|
||||
import os
|
||||
from typing import Dict, Any, List
|
||||
import os # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
from typing import Any, Dict, List, Optional, Tuple # noqa: E402
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
@@ -46,6 +47,108 @@ except ImportError:
|
||||
# Initialize logger for config operations
|
||||
config_logger = get_logger("prompt_manager.config")
|
||||
|
||||
# Environment variable listing extra directories (os.pathsep-separated) that
|
||||
# may be used as gallery roots in addition to ComfyUI's own directories.
|
||||
EXTRA_GALLERY_ROOTS_ENV = "PROMPT_MANAGER_EXTRA_GALLERY_ROOTS"
|
||||
|
||||
# Environment variable overriding where config.json is read from and saved to.
|
||||
CONFIG_PATH_ENV = "PROMPT_MANAGER_CONFIG_PATH"
|
||||
|
||||
# ComfyUI folder_paths getters whose directories are valid gallery parents.
|
||||
_COMFYUI_DIRECTORY_GETTERS = (
|
||||
"get_output_directory",
|
||||
"get_input_directory",
|
||||
"get_temp_directory",
|
||||
"get_user_directory",
|
||||
)
|
||||
|
||||
|
||||
def _canonical_path(path: str) -> str:
|
||||
"""Return a path normalised for comparison (realpath + normcase)."""
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def write_private_json(path: str, data: Any) -> None:
|
||||
"""Write ``data`` as JSON to ``path`` readable by the current user only.
|
||||
|
||||
config.json can hold an API key (integrations.lora_manager), so it is
|
||||
created with mode 0600 and, on POSIX, an existing more open file is
|
||||
tightened too. Windows has no POSIX modes; there the chmod is a best
|
||||
effort that only affects the read-only bit. Parent directories are
|
||||
created as needed. Raises OSError when the file cannot be written.
|
||||
"""
|
||||
import json
|
||||
|
||||
parent_dir = os.path.dirname(path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
if os.name == "posix":
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _is_filesystem_root(path: str) -> bool:
|
||||
"""True for '/' on POSIX and drive roots such as 'C:\\' on Windows."""
|
||||
return os.path.dirname(path) == path
|
||||
|
||||
|
||||
def _import_folder_paths():
|
||||
"""ComfyUI's folder_paths module, or None outside ComfyUI."""
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
return None
|
||||
return folder_paths
|
||||
|
||||
|
||||
def _comfyui_base_directory() -> Optional[str]:
|
||||
"""ComfyUI's base directory: folder_paths.base_path, else the output dir's
|
||||
parent."""
|
||||
folder_paths = _import_folder_paths()
|
||||
if folder_paths is None:
|
||||
return None
|
||||
base = getattr(folder_paths, "base_path", None)
|
||||
if isinstance(base, str) and base:
|
||||
return base
|
||||
getter = getattr(folder_paths, "get_output_directory", None)
|
||||
if getter is None:
|
||||
return None
|
||||
try:
|
||||
output_dir = getter()
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(output_dir, str) and output_dir:
|
||||
return os.path.dirname(os.path.realpath(output_dir))
|
||||
return None
|
||||
|
||||
|
||||
def _comfyui_directories() -> List[str]:
|
||||
"""Directories reported by ComfyUI's folder_paths module, if importable."""
|
||||
folder_paths = _import_folder_paths()
|
||||
if folder_paths is None:
|
||||
return []
|
||||
|
||||
found = []
|
||||
for getter_name in _COMFYUI_DIRECTORY_GETTERS:
|
||||
getter = getattr(folder_paths, getter_name, None)
|
||||
if getter is None:
|
||||
continue
|
||||
try:
|
||||
directory = getter()
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(directory, str) and directory:
|
||||
found.append(directory)
|
||||
return found
|
||||
|
||||
|
||||
def _extra_gallery_roots() -> List[str]:
|
||||
"""Directories the user opted in through PROMPT_MANAGER_EXTRA_GALLERY_ROOTS."""
|
||||
raw = os.environ.get(EXTRA_GALLERY_ROOTS_ENV, "")
|
||||
return [entry.strip() for entry in raw.split(os.pathsep) if entry.strip()]
|
||||
|
||||
|
||||
class GalleryConfig:
|
||||
"""Configuration class for the gallery monitoring and image processing system.
|
||||
@@ -106,8 +209,9 @@ class GalleryConfig:
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: A nested dictionary containing all gallery configuration
|
||||
sections: monitoring, tracking, database, web_interface, and performance.
|
||||
Each section contains the relevant configuration parameters as key-value pairs.
|
||||
sections: monitoring, tracking, database, web_interface, and
|
||||
performance. Each section contains the relevant configuration
|
||||
parameters as key-value pairs.
|
||||
|
||||
Example:
|
||||
config = GalleryConfig.get_config()
|
||||
@@ -166,7 +270,9 @@ class GalleryConfig:
|
||||
if "enabled" in monitoring:
|
||||
cls.MONITORING_ENABLED = monitoring["enabled"]
|
||||
if "directories" in monitoring:
|
||||
cls.MONITORING_DIRECTORIES = monitoring["directories"]
|
||||
directories = cls._validated_directories(monitoring["directories"])
|
||||
if directories is not None:
|
||||
cls.MONITORING_DIRECTORIES = directories
|
||||
if "extensions" in monitoring:
|
||||
cls.SUPPORTED_EXTENSIONS = monitoring["extensions"]
|
||||
if "processing_delay" in monitoring:
|
||||
@@ -200,6 +306,164 @@ class GalleryConfig:
|
||||
if "metadata_extraction_timeout" in performance:
|
||||
cls.METADATA_EXTRACTION_TIMEOUT = performance["metadata_extraction_timeout"]
|
||||
|
||||
@classmethod
|
||||
def _validated_directories(cls, directories: Any) -> Optional[List[str]]:
|
||||
"""Gallery roots from a config payload that pass validate_gallery_root.
|
||||
|
||||
A hand-edited config.json is as untrusted as a settings request:
|
||||
entries that fail validation are dropped with a warning (naming only
|
||||
the basename), and a value that is not a list is ignored entirely
|
||||
(``None`` is returned so the current roots stay as they are).
|
||||
"""
|
||||
if not isinstance(directories, list):
|
||||
config_logger.warning(
|
||||
"Ignoring gallery monitoring directories: expected a list"
|
||||
)
|
||||
return None
|
||||
kept = []
|
||||
for entry in directories:
|
||||
ok, reason = cls.validate_gallery_root(entry)
|
||||
if ok:
|
||||
kept.append(entry)
|
||||
continue
|
||||
label = os.path.basename(entry) if isinstance(entry, str) else entry
|
||||
config_logger.warning(f"Ignoring gallery root {label!r}: {reason}")
|
||||
return kept
|
||||
|
||||
@classmethod
|
||||
def allowed_gallery_parents(cls) -> List[str]:
|
||||
"""Canonical directories under which gallery roots may live.
|
||||
|
||||
Filesystem roots are never allowed as parents, even when listed in
|
||||
the environment, because that would re-open the whole disk.
|
||||
"""
|
||||
parents = []
|
||||
for candidate in _comfyui_directories() + _extra_gallery_roots():
|
||||
try:
|
||||
canonical = _canonical_path(candidate)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if _is_filesystem_root(canonical) or not os.path.isdir(canonical):
|
||||
continue
|
||||
if canonical not in parents:
|
||||
parents.append(canonical)
|
||||
return parents
|
||||
|
||||
@classmethod
|
||||
def path_anchors(cls) -> List[str]:
|
||||
"""Canonical directories that relative gallery paths are resolved against.
|
||||
|
||||
The ComfyUI base directory comes first, followed by the parent of each
|
||||
directory listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS, so an extra
|
||||
root is addressed by its own name (``gallery/sub``) rather than by an
|
||||
absolute path. The same anchors drive the public (relative) form of
|
||||
paths in API responses.
|
||||
"""
|
||||
anchors = []
|
||||
base = _comfyui_base_directory()
|
||||
candidates = [base] if base else []
|
||||
candidates.extend(os.path.dirname(root) for root in _extra_gallery_roots())
|
||||
for candidate in candidates:
|
||||
try:
|
||||
canonical = _canonical_path(candidate)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if canonical not in anchors:
|
||||
anchors.append(canonical)
|
||||
return anchors
|
||||
|
||||
@classmethod
|
||||
def resolve_gallery_root(cls, path: str) -> str:
|
||||
"""Canonical absolute path for a gallery root given in any accepted form.
|
||||
|
||||
Absolute paths are canonicalised as-is. Relative paths are tried
|
||||
against each :meth:`path_anchors` entry and the first existing match
|
||||
wins; otherwise the first anchor (or the current directory when there
|
||||
is none) is used, so the caller's existence check reports it.
|
||||
"""
|
||||
path = path.strip()
|
||||
if os.path.isabs(path):
|
||||
return _canonical_path(path)
|
||||
anchors = cls.path_anchors()
|
||||
for anchor in anchors:
|
||||
candidate = os.path.join(anchor, path)
|
||||
if os.path.exists(candidate):
|
||||
return _canonical_path(candidate)
|
||||
return _canonical_path(os.path.join(anchors[0], path) if anchors else path)
|
||||
|
||||
@classmethod
|
||||
def validate_gallery_root(cls, path: Any) -> Tuple[bool, str]:
|
||||
"""Check whether ``path`` may be used as a gallery root.
|
||||
|
||||
Returns:
|
||||
(True, "") when the directory lies inside one of
|
||||
:meth:`allowed_gallery_parents`; otherwise (False, reason).
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
return False, "Gallery root must be a non-empty path"
|
||||
|
||||
try:
|
||||
canonical = cls.resolve_gallery_root(path)
|
||||
except (OSError, ValueError):
|
||||
return False, "Gallery root could not be resolved"
|
||||
|
||||
if _is_filesystem_root(canonical):
|
||||
return False, "Gallery root cannot be a filesystem root"
|
||||
if canonical == _canonical_path(os.path.expanduser("~")):
|
||||
return False, "Gallery root cannot be the home directory"
|
||||
if not os.path.exists(canonical):
|
||||
return False, "Gallery root does not exist"
|
||||
if not os.path.isdir(canonical):
|
||||
return False, "Gallery root is not a directory"
|
||||
|
||||
candidate = Path(canonical)
|
||||
for parent in cls.allowed_gallery_parents():
|
||||
if candidate.is_relative_to(Path(parent)):
|
||||
return True, ""
|
||||
|
||||
return (
|
||||
False,
|
||||
"Gallery root must be inside a ComfyUI directory "
|
||||
f"(output, input, temp, user) or one listed in {EXTRA_GALLERY_ROOTS_ENV}",
|
||||
)
|
||||
|
||||
|
||||
class IntegrationConfig:
|
||||
"""Configuration for third-party extension integrations.
|
||||
|
||||
Manages opt-in integration settings for extensions like LoraManager.
|
||||
All integrations are disabled by default so PromptManager works standalone.
|
||||
"""
|
||||
|
||||
# LoraManager integration
|
||||
LORA_MANAGER_ENABLED = False
|
||||
LORA_MANAGER_PATH = "" # Auto-detected if empty
|
||||
LORA_TRIGGER_WORDS_ENABLED = False # Auto-inject trigger words into prompts
|
||||
CIVITAI_API_KEY = "" # Required to download NSFW example images
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"lora_manager": {
|
||||
"enabled": cls.LORA_MANAGER_ENABLED,
|
||||
"path": cls.LORA_MANAGER_PATH,
|
||||
"trigger_words_enabled": cls.LORA_TRIGGER_WORDS_ENABLED,
|
||||
"civitai_api_key": cls.CIVITAI_API_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Dict[str, Any]):
|
||||
lora = new_config.get("lora_manager", {})
|
||||
if "enabled" in lora:
|
||||
cls.LORA_MANAGER_ENABLED = lora["enabled"]
|
||||
if "path" in lora:
|
||||
cls.LORA_MANAGER_PATH = lora["path"]
|
||||
if "trigger_words_enabled" in lora:
|
||||
cls.LORA_TRIGGER_WORDS_ENABLED = lora["trigger_words_enabled"]
|
||||
if "civitai_api_key" in lora:
|
||||
cls.CIVITAI_API_KEY = lora["civitai_api_key"]
|
||||
|
||||
|
||||
class PromptManagerConfig:
|
||||
"""Main configuration class for PromptManager core functionality.
|
||||
@@ -235,11 +499,36 @@ class PromptManagerConfig:
|
||||
RESULT_TIMEOUT = 5 # Seconds to auto-hide results in ComfyUI node
|
||||
SHOW_TEST_BUTTON = False # Show API test button in node UI
|
||||
WEBUI_DISPLAY_MODE = "newtab" # 'popup' or 'newtab'
|
||||
INFINITE_SCROLL = False # Dashboard list: load the next page on scroll by default
|
||||
|
||||
# Performance settings
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
ENABLE_FUZZY_SEARCH = False # Requires fuzzywuzzy
|
||||
AUTO_BACKUP_INTERVAL = 24 # Hours
|
||||
# Threads for the output scan and thumbnail generation; half the cores by
|
||||
# default so ComfyUI's own generation keeps CPU headroom. 1..max_worker_threads().
|
||||
DEFAULT_WORKER_THREADS = max(1, (os.cpu_count() or 1) // 2)
|
||||
WORKER_THREADS = DEFAULT_WORKER_THREADS
|
||||
|
||||
@classmethod
|
||||
def max_worker_threads(cls) -> int:
|
||||
"""Largest accepted worker-thread count: the detected core count."""
|
||||
return os.cpu_count() or 1
|
||||
|
||||
@classmethod
|
||||
def clamp_worker_threads(cls, value, fallback: int) -> int:
|
||||
"""Coerce a worker count from config.json into ``[1, max_worker_threads()]``.
|
||||
|
||||
Non-numeric values (and booleans) return ``fallback`` so a hand-edited
|
||||
file cannot disable the setting.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return fallback
|
||||
if isinstance(value, str) and value.strip().isdigit():
|
||||
value = int(value.strip())
|
||||
if not isinstance(value, int):
|
||||
return fallback
|
||||
return max(1, min(value, cls.max_worker_threads()))
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
@@ -267,17 +556,33 @@ class PromptManagerConfig:
|
||||
"result_timeout": cls.RESULT_TIMEOUT,
|
||||
"show_test_button": cls.SHOW_TEST_BUTTON,
|
||||
"webui_display_mode": cls.WEBUI_DISPLAY_MODE,
|
||||
"infinite_scroll": cls.INFINITE_SCROLL,
|
||||
},
|
||||
"performance": {
|
||||
"max_search_results": cls.MAX_SEARCH_RESULTS,
|
||||
"enable_fuzzy_search": cls.ENABLE_FUZZY_SEARCH,
|
||||
"auto_backup_interval": cls.AUTO_BACKUP_INTERVAL,
|
||||
"worker_threads": cls.WORKER_THREADS,
|
||||
},
|
||||
"gallery": GalleryConfig.get_config(),
|
||||
"integrations": IntegrationConfig.get_config(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, config_path: str):
|
||||
def get_config_path(cls) -> str:
|
||||
"""Path of the persisted config.json.
|
||||
|
||||
Honours ``PROMPT_MANAGER_CONFIG_PATH`` when set; otherwise the file
|
||||
lives at the repository root next to ``pyproject.toml``.
|
||||
"""
|
||||
override = os.environ.get(CONFIG_PATH_ENV, "").strip()
|
||||
if override:
|
||||
return override
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(repo_root, "config.json")
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, config_path: Optional[str] = None):
|
||||
"""Load configuration settings from a JSON file.
|
||||
|
||||
Reads configuration from the specified JSON file and updates the current
|
||||
@@ -286,7 +591,8 @@ class PromptManagerConfig:
|
||||
|
||||
Args:
|
||||
config_path (str): Path to the JSON configuration file to load.
|
||||
Can be relative or absolute path.
|
||||
Can be relative or absolute path. Defaults to
|
||||
:meth:`get_config_path`.
|
||||
|
||||
Raises:
|
||||
The method handles all exceptions internally and logs errors rather
|
||||
@@ -298,6 +604,9 @@ class PromptManagerConfig:
|
||||
"""
|
||||
import json
|
||||
|
||||
if config_path is None:
|
||||
config_path = cls.get_config_path()
|
||||
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
@@ -310,7 +619,7 @@ class PromptManagerConfig:
|
||||
config_logger.info(f"Config file not found: {config_path}, using defaults")
|
||||
|
||||
@classmethod
|
||||
def save_to_file(cls, config_path: str):
|
||||
def save_to_file(cls, config_path: Optional[str] = None):
|
||||
"""Save the current configuration to a JSON file.
|
||||
|
||||
Serializes the complete configuration (including gallery settings) to
|
||||
@@ -319,6 +628,7 @@ class PromptManagerConfig:
|
||||
Args:
|
||||
config_path (str): Path where the JSON configuration file should be saved.
|
||||
Parent directories will be created if they don't exist.
|
||||
Defaults to :meth:`get_config_path`.
|
||||
|
||||
Raises:
|
||||
The method handles all exceptions internally and logs errors rather
|
||||
@@ -328,15 +638,11 @@ class PromptManagerConfig:
|
||||
PromptManagerConfig.save_to_file('backup_config.json')
|
||||
PromptManagerConfig.save_to_file('/etc/comfyui/prompt_manager.json')
|
||||
"""
|
||||
import json
|
||||
if config_path is None:
|
||||
config_path = cls.get_config_path()
|
||||
|
||||
try:
|
||||
config = cls.get_config()
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
write_private_json(config_path, cls.get_config())
|
||||
config_logger.info(f"Saved configuration to {config_path}")
|
||||
except Exception as e:
|
||||
config_logger.error(f"Error saving config to {config_path}: {e}")
|
||||
@@ -352,7 +658,8 @@ class PromptManagerConfig:
|
||||
Args:
|
||||
new_config (Dict[str, Any]): Nested dictionary containing configuration
|
||||
updates. Should follow the same structure as returned by get_config().
|
||||
Valid top-level keys are: 'database', 'web_ui', 'performance', 'gallery'.
|
||||
Valid top-level keys are: 'database', 'web_ui', 'performance',
|
||||
'gallery'.
|
||||
|
||||
Example:
|
||||
new_settings = {
|
||||
@@ -376,6 +683,8 @@ class PromptManagerConfig:
|
||||
cls.SHOW_TEST_BUTTON = web_ui["show_test_button"]
|
||||
if "webui_display_mode" in web_ui:
|
||||
cls.WEBUI_DISPLAY_MODE = web_ui["webui_display_mode"]
|
||||
if isinstance(web_ui.get("infinite_scroll"), bool):
|
||||
cls.INFINITE_SCROLL = web_ui["infinite_scroll"]
|
||||
|
||||
performance = new_config.get("performance", {})
|
||||
if "max_search_results" in performance:
|
||||
@@ -384,16 +693,22 @@ class PromptManagerConfig:
|
||||
cls.ENABLE_FUZZY_SEARCH = performance["enable_fuzzy_search"]
|
||||
if "auto_backup_interval" in performance:
|
||||
cls.AUTO_BACKUP_INTERVAL = performance["auto_backup_interval"]
|
||||
if "worker_threads" in performance:
|
||||
cls.WORKER_THREADS = cls.clamp_worker_threads(
|
||||
performance["worker_threads"], cls.WORKER_THREADS
|
||||
)
|
||||
|
||||
# Update gallery config
|
||||
if "gallery" in new_config:
|
||||
GalleryConfig.update_config(new_config["gallery"])
|
||||
|
||||
# Update integration config
|
||||
if "integrations" in new_config:
|
||||
IntegrationConfig.update_config(new_config["integrations"])
|
||||
|
||||
|
||||
# Load configuration on import
|
||||
try:
|
||||
config_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
config_file = os.path.join(config_dir, "config.json")
|
||||
PromptManagerConfig.load_from_file(config_file)
|
||||
PromptManagerConfig.load_from_file()
|
||||
except Exception as e:
|
||||
config_logger.error(f"Error during config initialization: {e}")
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
"""Utilities for LoraManager integration.
|
||||
|
||||
Provides detection, metadata reading, and trigger word lookup for
|
||||
ComfyUI-Lora-Manager (https://github.com/willmiao/ComfyUI-Lora-Manager).
|
||||
|
||||
All functions are safe to call when LoraManager is not installed — they
|
||||
return empty results rather than raising.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("prompt_manager.lora_utils")
|
||||
|
||||
# ── LoraManager detection ────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_comfyui_root() -> Optional[Path]:
|
||||
"""Walk upward from this file to find the ComfyUI root (contains main.py).
|
||||
|
||||
Tries both the resolved (real) path and the unresolved path to handle
|
||||
symlinked custom_nodes installations.
|
||||
"""
|
||||
start_paths = [Path(__file__).resolve().parent]
|
||||
|
||||
# If installed via symlink, the unresolved path leads through custom_nodes/
|
||||
raw_path = Path(__file__).parent
|
||||
if raw_path.resolve() != raw_path:
|
||||
start_paths.append(raw_path)
|
||||
|
||||
# Also try via folder_paths if available (ComfyUI runtime)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
base = Path(folder_paths.base_path)
|
||||
if base.is_dir():
|
||||
return base
|
||||
except (ImportError, AttributeError):
|
||||
# folder_paths unavailable — not running inside ComfyUI runtime
|
||||
pass
|
||||
|
||||
for start in start_paths:
|
||||
current = start
|
||||
for _ in range(10):
|
||||
if (current / "main.py").exists() and (current / "custom_nodes").exists():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_lora_manager(custom_path: str = "") -> Optional[str]:
|
||||
"""Return the absolute path to ComfyUI-Lora-Manager if installed.
|
||||
|
||||
Args:
|
||||
custom_path: User-provided override path. Checked first.
|
||||
|
||||
Returns:
|
||||
Absolute path string, or None if not found.
|
||||
"""
|
||||
# 1. User override
|
||||
if custom_path:
|
||||
p = Path(custom_path)
|
||||
if p.is_dir() and _looks_like_lora_manager(p):
|
||||
return str(p.resolve())
|
||||
|
||||
# 2. Auto-detect via custom_nodes (case-insensitive scan)
|
||||
root = find_comfyui_root()
|
||||
if root:
|
||||
custom_nodes = root / "custom_nodes"
|
||||
if custom_nodes.is_dir():
|
||||
for entry in custom_nodes.iterdir():
|
||||
if (
|
||||
entry.is_dir()
|
||||
and "lora" in entry.name.lower()
|
||||
and "manager" in entry.name.lower()
|
||||
and _looks_like_lora_manager(entry)
|
||||
):
|
||||
return str(entry.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_lora_manager(path: Path) -> bool:
|
||||
"""Heuristic: does this directory look like a LoraManager install?"""
|
||||
# Must have __init__.py (ComfyUI extension) or README.md
|
||||
has_init = (path / "__init__.py").exists()
|
||||
if not has_init:
|
||||
return False
|
||||
# Check for characteristic structure: py/ dir, or any .metadata.json nearby
|
||||
return (path / "py").is_dir() or (path / "lora_manager").is_dir()
|
||||
|
||||
|
||||
def _public_path_anchors() -> List[str]:
|
||||
"""Directories the API's public (relative) paths are rendered against."""
|
||||
try:
|
||||
from .config import GalleryConfig
|
||||
except ImportError:
|
||||
try:
|
||||
from config import GalleryConfig
|
||||
except ImportError:
|
||||
return []
|
||||
try:
|
||||
return list(GalleryConfig.path_anchors())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def custom_nodes_directories() -> List[Path]:
|
||||
"""ComfyUI ``custom_nodes`` directories a LoraManager install may live in.
|
||||
|
||||
Inside ComfyUI these come from ``folder_paths`` (``base_path`` plus any
|
||||
registered ``custom_nodes`` folders); outside it, the parent of this
|
||||
package, which is ``custom_nodes`` for a normal install.
|
||||
"""
|
||||
found: List[Path] = []
|
||||
|
||||
def add(candidate) -> None:
|
||||
try:
|
||||
resolved = Path(candidate).resolve()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return
|
||||
if resolved not in found:
|
||||
found.append(resolved)
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
if folder_paths is not None:
|
||||
base = getattr(folder_paths, "base_path", None)
|
||||
if isinstance(base, str) and base:
|
||||
add(Path(base) / "custom_nodes")
|
||||
getter = getattr(folder_paths, "get_folder_paths", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
registered = getter("custom_nodes") or []
|
||||
except Exception:
|
||||
registered = []
|
||||
for entry in registered:
|
||||
add(entry)
|
||||
if not found:
|
||||
add(Path(__file__).resolve().parent.parent.parent)
|
||||
return found
|
||||
|
||||
|
||||
def resolve_lora_manager_path(path) -> Optional[str]:
|
||||
"""Canonical form of ``path`` when it may be used as the LoraManager install.
|
||||
|
||||
Accepted: an existing directory that is a direct child of one of
|
||||
:func:`custom_nodes_directories` and whose name contains ``lora``
|
||||
(case-insensitive). A relative path is tried against the ComfyUI root
|
||||
and each custom_nodes directory. Anything else, including a symlink
|
||||
that escapes custom_nodes, yields None.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
return None
|
||||
path = path.strip()
|
||||
roots = []
|
||||
for root in custom_nodes_directories():
|
||||
try:
|
||||
roots.append(Path(os.path.normcase(os.path.realpath(str(root)))))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
candidates = [path]
|
||||
if not os.path.isabs(path):
|
||||
# Public (API) paths are relative to these anchors; the ComfyUI root
|
||||
# and the custom_nodes directories cover hand-typed relative paths.
|
||||
bases = list(_public_path_anchors())
|
||||
comfy_root = find_comfyui_root()
|
||||
if comfy_root:
|
||||
bases.append(comfy_root)
|
||||
bases.extend(root.parent for root in roots)
|
||||
bases.extend(roots)
|
||||
candidates = [os.path.join(str(base), path) for base in bases]
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
canonical = Path(os.path.normcase(os.path.realpath(candidate)))
|
||||
is_dir = canonical.is_dir()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
if not is_dir or "lora" not in canonical.name.lower():
|
||||
continue
|
||||
if canonical.parent in roots:
|
||||
return str(canonical)
|
||||
return None
|
||||
|
||||
|
||||
# ── Metadata reading ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_lora_directories(lora_manager_path: str) -> List[str]:
|
||||
"""Find directories that contain LoRA models (with .metadata.json files).
|
||||
|
||||
Searches: ComfyUI models/loras, extra_model_paths.yaml lora dirs,
|
||||
and the LoraManager extension dir itself.
|
||||
"""
|
||||
dirs = set()
|
||||
lm_path = Path(lora_manager_path)
|
||||
|
||||
root = find_comfyui_root()
|
||||
if root:
|
||||
# Default models/loras
|
||||
models_loras = root / "models" / "loras"
|
||||
if models_loras.is_dir():
|
||||
dirs.add(str(models_loras.resolve()))
|
||||
|
||||
# Extra model paths from ComfyUI config
|
||||
for extra_dir in _get_extra_lora_paths(root):
|
||||
if extra_dir.is_dir():
|
||||
dirs.add(str(extra_dir.resolve()))
|
||||
|
||||
# Also try folder_paths at runtime (catches all configured paths)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
for p in folder_paths.get_folder_paths("loras"):
|
||||
pp = Path(p)
|
||||
if pp.is_dir():
|
||||
dirs.add(str(pp.resolve()))
|
||||
except (ImportError, AttributeError):
|
||||
# folder_paths unavailable — not running inside ComfyUI runtime
|
||||
pass
|
||||
|
||||
# Check for any .metadata.json in the LoraManager dir tree
|
||||
for meta in lm_path.rglob("*.metadata.json"):
|
||||
dirs.add(str(meta.parent.resolve()))
|
||||
|
||||
return sorted(dirs)
|
||||
|
||||
|
||||
def _get_extra_lora_paths(comfyui_root: Path) -> List[Path]:
|
||||
"""Parse extra_model_paths.yaml for additional LoRA directories."""
|
||||
results = []
|
||||
for name in ("extra_model_paths.yaml", "extra_model_paths.yml"):
|
||||
config_file = comfyui_root / name
|
||||
if not config_file.exists():
|
||||
continue
|
||||
try:
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load(config_file.read_text())
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
for section in config.values():
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
base = Path(section.get("base_path", ""))
|
||||
loras_val = section.get("loras", "")
|
||||
if not loras_val:
|
||||
continue
|
||||
for line in str(loras_val).strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
p = Path(line)
|
||||
if not p.is_absolute():
|
||||
p = base / line
|
||||
if p.is_dir():
|
||||
results.append(p)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse {config_file}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
def read_lora_metadata(metadata_path: Path) -> Optional[Dict]:
|
||||
"""Read and parse a single .metadata.json file.
|
||||
|
||||
Returns:
|
||||
Parsed dict, or None on failure.
|
||||
"""
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug(f"Failed to read {metadata_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_civitai(metadata: Dict) -> Dict:
|
||||
"""Safely get the civitai dict, handling None values."""
|
||||
return metadata.get("civitai") or {}
|
||||
|
||||
|
||||
def get_trigger_words_from_metadata(metadata: Dict) -> List[str]:
|
||||
"""Extract trigger words from a parsed LoraManager metadata dict."""
|
||||
civitai = _get_civitai(metadata)
|
||||
words = civitai.get("trainedWords", [])
|
||||
if isinstance(words, list):
|
||||
return [w.strip() for w in words if isinstance(w, str) and w.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def get_example_prompt_from_metadata(metadata: Dict) -> Optional[str]:
|
||||
"""Extract an example prompt from civitai image metadata.
|
||||
|
||||
Looks at civitai.images[].meta.prompt for the first available example.
|
||||
"""
|
||||
civitai = _get_civitai(metadata)
|
||||
images = civitai.get("images", []) or []
|
||||
for img in images:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
meta = img.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
prompt = meta.get("prompt", "")
|
||||
if isinstance(prompt, str) and prompt.strip():
|
||||
return prompt.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_civitai_image_urls(metadata: Dict) -> List[str]:
|
||||
"""Extract all civitai example image URLs from metadata."""
|
||||
civitai = _get_civitai(metadata)
|
||||
urls = []
|
||||
for img in civitai.get("images", []) or []:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
url = img.get("url", "")
|
||||
if isinstance(url, str) and url.strip():
|
||||
urls.append(url.strip())
|
||||
return urls
|
||||
|
||||
|
||||
def get_model_name_from_metadata(metadata: Dict) -> str:
|
||||
"""Extract the model display name from metadata."""
|
||||
name = metadata.get("model_name", "")
|
||||
if not name:
|
||||
civitai = _get_civitai(metadata)
|
||||
model = civitai.get("model") or {}
|
||||
name = model.get("name", "")
|
||||
if not name:
|
||||
name = metadata.get("file_name", "unknown")
|
||||
return name
|
||||
|
||||
|
||||
def get_preview_images_from_metadata(metadata: Dict, metadata_path: Path) -> List[str]:
|
||||
"""Find all local preview/example image paths for a LoRA.
|
||||
|
||||
Returns:
|
||||
List of absolute path strings to image files.
|
||||
"""
|
||||
results = []
|
||||
lora_dir = metadata_path.parent
|
||||
file_name = metadata.get("file_name", "")
|
||||
if not file_name:
|
||||
stem = metadata_path.name.replace(".metadata.json", "")
|
||||
file_name = stem
|
||||
|
||||
base_name = Path(file_name).stem
|
||||
|
||||
# Check standard preview naming conventions
|
||||
for ext in (
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".preview.png",
|
||||
".preview.jpg",
|
||||
".preview.jpeg",
|
||||
):
|
||||
candidate = lora_dir / f"{base_name}{ext}"
|
||||
if candidate.exists():
|
||||
results.append(str(candidate.resolve()))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_preview_image_from_metadata(
|
||||
metadata: Dict, metadata_path: Path
|
||||
) -> Optional[str]:
|
||||
"""Find the first preview image path for a LoRA (backward compat)."""
|
||||
images = get_preview_images_from_metadata(metadata, metadata_path)
|
||||
return images[0] if images else None
|
||||
|
||||
|
||||
_THUMB_MAX_SIZE = 512
|
||||
|
||||
# Only these hosts may ever receive the CivitAI API key, and only over HTTPS.
|
||||
CIVITAI_HOSTS = frozenset(
|
||||
{"civitai.com", "www.civitai.com", "api.civitai.com", "image.civitai.com"}
|
||||
)
|
||||
MAX_CIVITAI_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
_DOWNLOAD_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
|
||||
"""Never follow a redirect: urllib would re-send the Bearer header to it."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
logger.warning(f"Refusing redirect from {req.full_url} to {newurl}")
|
||||
return None
|
||||
|
||||
|
||||
def _build_opener(*handlers) -> urllib.request.OpenerDirector:
|
||||
"""Build the outbound opener; extra handlers let tests serve canned responses."""
|
||||
return urllib.request.build_opener(_RefuseRedirects, *handlers)
|
||||
|
||||
|
||||
_opener = _build_opener()
|
||||
|
||||
|
||||
def _open_url(req: urllib.request.Request, timeout: float = 10):
|
||||
"""Open ``req`` through the redirect-refusing opener."""
|
||||
return _opener.open(req, timeout=timeout)
|
||||
|
||||
|
||||
def is_civitai_url(url: str) -> bool:
|
||||
"""True when ``url`` is an HTTPS URL on an allow-listed CivitAI host."""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
hostname = (parts.hostname or "").lower()
|
||||
return (
|
||||
parts.scheme == "https"
|
||||
and hostname in CIVITAI_HOSTS
|
||||
and parts.username is None
|
||||
and parts.password is None
|
||||
and port in (None, 443)
|
||||
)
|
||||
|
||||
|
||||
def _read_capped(resp, cap: int) -> Optional[bytes]:
|
||||
"""Read a response body, refusing anything larger than ``cap`` bytes."""
|
||||
declared = resp.headers.get("Content-Length")
|
||||
if declared is not None:
|
||||
try:
|
||||
if int(declared) > cap:
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = resp.read(_DOWNLOAD_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > cap:
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _download_one(url: str, local_path: Path, api_key: str) -> Optional[str]:
|
||||
"""Download a single CivitAI image, resize to thumbnail, save as JPEG.
|
||||
|
||||
Refuses any URL that is not HTTPS on an allow-listed CivitAI host so the
|
||||
API key is never sent elsewhere, and refuses bodies larger than
|
||||
MAX_CIVITAI_DOWNLOAD_BYTES.
|
||||
"""
|
||||
if not is_civitai_url(url):
|
||||
logger.warning(f"Refusing non-CivitAI download URL: {url}")
|
||||
return None
|
||||
try:
|
||||
headers = {"User-Agent": "ComfyUI-PromptManager/1.0"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with _open_url(req, timeout=10) as resp:
|
||||
raw = _read_capped(resp, MAX_CIVITAI_DOWNLOAD_BYTES)
|
||||
if raw is None:
|
||||
logger.warning(f"Refusing oversized download: {url}")
|
||||
return None
|
||||
|
||||
# Resize to thumbnail to save disk space
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(raw))
|
||||
img.thumbnail((_THUMB_MAX_SIZE, _THUMB_MAX_SIZE), Image.LANCZOS)
|
||||
img = img.convert("RGB")
|
||||
img.save(str(local_path), "JPEG", quality=85)
|
||||
|
||||
return str(local_path.resolve())
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def download_civitai_images(
|
||||
metadata: Dict, metadata_path: Path, cache_dir: Path, api_key: str = ""
|
||||
) -> List[str]:
|
||||
"""Download civitai example images to a local cache directory.
|
||||
|
||||
Uses thumbnail URLs (512px) instead of full-size originals, and
|
||||
downloads in parallel (up to 8 concurrent) for speed.
|
||||
|
||||
Args:
|
||||
api_key: CivitAI API key for authenticated downloads (NSFW content).
|
||||
|
||||
Returns:
|
||||
List of absolute paths to downloaded image files.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
civitai = _get_civitai(metadata)
|
||||
images = civitai.get("images", []) or []
|
||||
if not images:
|
||||
return []
|
||||
|
||||
file_name = metadata.get("file_name", "")
|
||||
if not file_name:
|
||||
file_name = metadata_path.name.replace(".metadata.json", "")
|
||||
lora_stem = Path(file_name).stem
|
||||
|
||||
lora_cache = cache_dir / lora_stem
|
||||
lora_cache.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build download tasks
|
||||
cached = []
|
||||
tasks = [] # (url, local_path)
|
||||
for img in images:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
url = img.get("url", "")
|
||||
if not isinstance(url, str) or not is_civitai_url(url):
|
||||
continue
|
||||
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
local_path = lora_cache / f"{url_hash}.jpg"
|
||||
|
||||
if local_path.exists():
|
||||
cached.append(str(local_path.resolve()))
|
||||
else:
|
||||
tasks.append((url, local_path))
|
||||
|
||||
if not tasks:
|
||||
return cached
|
||||
|
||||
# Download in parallel
|
||||
downloaded = []
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = [
|
||||
pool.submit(_download_one, url, path, api_key) for url, path in tasks
|
||||
]
|
||||
for fut in futures:
|
||||
result = fut.result()
|
||||
if result:
|
||||
downloaded.append(result)
|
||||
|
||||
return cached + downloaded
|
||||
|
||||
|
||||
def get_lora_image_cache_dir() -> Path:
|
||||
"""Get the directory used to cache downloaded LoRA example images."""
|
||||
# Store in the extension's own directory
|
||||
ext_root = Path(__file__).resolve().parent.parent
|
||||
cache = ext_root / "data" / "lora_images"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def get_example_images_dir(lora_manager_path: str) -> Optional[str]:
|
||||
"""Find the LoraManager example_images directory."""
|
||||
lm_path = Path(lora_manager_path)
|
||||
|
||||
# Direct subdirectory
|
||||
candidate = lm_path / "example_images"
|
||||
if candidate.is_dir():
|
||||
return str(candidate.resolve())
|
||||
|
||||
# Search one level in user data dirs
|
||||
for child in lm_path.iterdir():
|
||||
if child.is_dir():
|
||||
sub = child / "example_images"
|
||||
if sub.is_dir():
|
||||
return str(sub.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Trigger word cache & injection ───────────────────────────────────
|
||||
|
||||
_LORA_PATTERN = re.compile(r"<lora:([^:>]+):[^>]+>", re.IGNORECASE)
|
||||
|
||||
|
||||
class TriggerWordCache:
|
||||
"""Thread-safe cache mapping LoRA names to their trigger words.
|
||||
|
||||
Built lazily on first access, refreshable on demand.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, List[str]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._loaded = False
|
||||
|
||||
def load(self, lora_manager_path: str) -> int:
|
||||
"""Scan LoRA metadata files and build the trigger word mapping.
|
||||
|
||||
Returns:
|
||||
Number of LoRAs with trigger words found.
|
||||
"""
|
||||
new_cache: Dict[str, List[str]] = {}
|
||||
|
||||
lora_dirs = find_lora_directories(lora_manager_path)
|
||||
for lora_dir in lora_dirs:
|
||||
dir_path = Path(lora_dir)
|
||||
for meta_file in dir_path.rglob("*.metadata.json"):
|
||||
metadata = read_lora_metadata(meta_file)
|
||||
if not metadata:
|
||||
continue
|
||||
|
||||
words = get_trigger_words_from_metadata(metadata)
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Key by filename stem (what appears in <lora:NAME:weight>)
|
||||
file_name = metadata.get("file_name", "")
|
||||
if file_name:
|
||||
stem = Path(file_name).stem
|
||||
new_cache[stem.lower()] = words
|
||||
|
||||
# Also key by the metadata file stem
|
||||
meta_stem = meta_file.name.replace(".metadata.json", "")
|
||||
if meta_stem.lower() not in new_cache:
|
||||
new_cache[meta_stem.lower()] = words
|
||||
|
||||
with self._lock:
|
||||
self._cache = new_cache
|
||||
self._loaded = True
|
||||
|
||||
logger.info(
|
||||
f"Trigger word cache loaded: {len(new_cache)} LoRAs with trigger words"
|
||||
)
|
||||
return len(new_cache)
|
||||
|
||||
def get_trigger_words(self, lora_name: str) -> List[str]:
|
||||
"""Look up trigger words for a LoRA by name (case-insensitive)."""
|
||||
with self._lock:
|
||||
return self._cache.get(lora_name.lower(), [])
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
with self._lock:
|
||||
return self._loaded
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._loaded = False
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
_trigger_cache = TriggerWordCache()
|
||||
|
||||
|
||||
def get_trigger_cache() -> TriggerWordCache:
|
||||
return _trigger_cache
|
||||
|
||||
|
||||
def inject_trigger_words(text: str, cache: TriggerWordCache) -> Tuple[str, List[str]]:
|
||||
"""Scan text for <lora:NAME:WEIGHT> tags and append trigger words.
|
||||
|
||||
Args:
|
||||
text: The prompt text potentially containing lora tags.
|
||||
cache: Populated TriggerWordCache instance.
|
||||
|
||||
Returns:
|
||||
Tuple of (modified_text, list_of_injected_words).
|
||||
If no trigger words found, returns the original text unchanged.
|
||||
"""
|
||||
if not cache.is_loaded:
|
||||
return text, []
|
||||
|
||||
matches = _LORA_PATTERN.findall(text)
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
all_words = []
|
||||
for lora_name in matches:
|
||||
words = cache.get_trigger_words(lora_name)
|
||||
for w in words:
|
||||
if w.lower() not in text.lower() and w not in all_words:
|
||||
all_words.append(w)
|
||||
|
||||
if not all_words:
|
||||
return text, []
|
||||
|
||||
injected = ", ".join(all_words)
|
||||
return f"{text}, {injected}", all_words
|
||||
@@ -1,9 +1,14 @@
|
||||
[project]
|
||||
name = "promptmanager"
|
||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||
version = "3.2.0"
|
||||
version = "3.3.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=84.0.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ComfyAssets/ComfyUI_PromptManager"
|
||||
@@ -18,10 +23,5 @@ Icon = "https://avatars.githubusercontent.com/u/213204677?s=200"
|
||||
line-length = 88
|
||||
target-version = ["py310"]
|
||||
|
||||
[tool.flake8]
|
||||
max-line-length = 88
|
||||
extend-ignore = ["E203", "W503"]
|
||||
exclude = ["reference/", ".git", "__pycache__", "web/", "venv/"]
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", "reference"]
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# Runtime dependencies
|
||||
watchdog>=2.1.0
|
||||
Pillow>=8.0.0
|
||||
aiohttp>=3.8.0
|
||||
watchdog>=6.0.0
|
||||
Pillow>=12.3.0
|
||||
aiohttp>=3.14.5
|
||||
|
||||
# Tests and coverage
|
||||
coverage>=7.16.2
|
||||
numpy>=2.2.6 # WD14 engine tests
|
||||
|
||||
# Code quality
|
||||
black>=24.0.0
|
||||
flake8>=7.0.0
|
||||
black>=26.10.1
|
||||
flake8>=7.4.1
|
||||
|
||||
# Security scanning
|
||||
bandit[toml]>=1.7.0
|
||||
bandit[toml]>=1.9.4
|
||||
|
||||
# Pre-commit hooks
|
||||
pre-commit>=4.0.0
|
||||
pre-commit>=4.6.2
|
||||
|
||||
@@ -1,26 +1,4 @@
|
||||
# Core dependencies for PromptManager
|
||||
# Note: Most dependencies are already included with ComfyUI
|
||||
|
||||
# Already included with Python standard library:
|
||||
# - sqlite3
|
||||
# - hashlib
|
||||
# - json
|
||||
# - datetime
|
||||
# - os
|
||||
# - typing
|
||||
# - threading
|
||||
# - uuid
|
||||
|
||||
# Required for gallery functionality:
|
||||
watchdog>=2.1.0 # For file system monitoring
|
||||
Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)
|
||||
|
||||
# Optional dependencies for enhanced search functionality:
|
||||
# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)
|
||||
# sqlalchemy>=1.4.0 # For advanced ORM features (optional)
|
||||
|
||||
# Development dependencies (optional):
|
||||
# pytest>=6.0.0 # For running tests
|
||||
# black>=22.0.0 # For code formatting
|
||||
# flake8>=4.0.0 # For linting
|
||||
# mypy>=0.910 # For type checking
|
||||
# Runtime dependencies for PromptManager.
|
||||
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
|
||||
watchdog>=2.1.0 # Output directory monitoring
|
||||
Pillow>=8.0.0 # Image metadata extraction and thumbnails
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ComfyUI_PromptManager Gallery System Restart Script.
|
||||
|
||||
This script reinitializes and restarts the automatic image gallery monitoring system
|
||||
for ComfyUI_PromptManager. Use this script after code modifications or when the
|
||||
gallery system needs to be reloaded.
|
||||
|
||||
The script performs the following operations:
|
||||
1. Initializes the database connection
|
||||
2. Creates a PromptTracker instance for linking prompts to images
|
||||
3. Sets up the ImageMonitor for real-time file system monitoring
|
||||
4. Starts monitoring ComfyUI output directories
|
||||
5. Reports system status and readiness
|
||||
|
||||
Usage:
|
||||
python restart_gallery.py
|
||||
|
||||
This is particularly useful during development when you need to restart the
|
||||
gallery monitoring system without restarting the entire ComfyUI application.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add current directory to Python path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
try:
|
||||
# Import and initialize the gallery system
|
||||
from database.operations import PromptDatabase
|
||||
from utils.prompt_tracker import get_prompt_tracker
|
||||
from utils.image_monitor import get_image_monitor
|
||||
|
||||
print("[RESTART] Restarting PromptManager Gallery System...")
|
||||
|
||||
# Initialize components using singleton getters
|
||||
db = PromptDatabase()
|
||||
tracker = get_prompt_tracker(db)
|
||||
monitor = get_image_monitor(db, tracker)
|
||||
|
||||
print("[SUCCESS] Components initialized successfully")
|
||||
|
||||
# Test image monitoring directories
|
||||
status = monitor.get_status()
|
||||
print(f"[INFO] Monitoring status: {status}")
|
||||
|
||||
# Start monitoring
|
||||
monitor.start_monitoring()
|
||||
print("[SUCCESS] Image monitoring restarted")
|
||||
|
||||
print("\n[READY] Gallery system restart completed!")
|
||||
print("Generate some images now to test the automatic linking.")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"[ERROR] Import error: {e}")
|
||||
print("Make sure you're running this from the PromptManager directory")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||