feat: Implement cache busting for static assets, remove client-side version mismatch banner, and add project overview documentation.

This commit is contained in:
Will Miao
2025-12-19 22:40:36 +08:00
parent 154ae82519
commit 63b087fc80
12 changed files with 132 additions and 110 deletions

84
GEMINI.md Normal file
View File

@@ -0,0 +1,84 @@
# ComfyUI LoRA Manager
## Project Overview
ComfyUI LoRA Manager is a comprehensive extension for ComfyUI that streamlines the organization, downloading, and application of LoRA models. It functions as both a custom node within ComfyUI and a standalone application.
**Key Features:**
* **Model Management:** Browse, organize, and download LoRA models (and Checkpoints/Embeddings) from Civitai and CivArchive.
* **Visualization:** Preview images, videos, and trigger words.
* **Workflow Integration:** "One-click" integration into ComfyUI workflows, preserving generation parameters.
* **Recipe System:** Save and share LoRA combinations as "recipes".
* **Architecture:** Hybrid Python backend (API, file management) and JavaScript/HTML frontend (Web UI).
## Directory Structure
* `py/`: Core Python backend source code.
* `lora_manager.py`: Main entry point for the ComfyUI node.
* `routes/`: API route definitions (using `aiohttp` in standalone, or ComfyUI's server).
* `services/`: Business logic (downloading, metadata, scanning).
* `nodes/`: ComfyUI custom node implementations.
* `static/`: Frontend static assets (CSS, JS, Images).
* `templates/`: HTML templates (Jinja2).
* `locales/`: Internationalization JSON files.
* `web/comfyui/`: JavaScript extensions specifically for the ComfyUI interface.
* `standalone.py`: Entry point for running the manager as a standalone web app.
* `tests/`: Backend tests.
* `requirements.txt`: Python runtime dependencies.
* `package.json`: Frontend development dependencies and test scripts.
## Building and Running
### Prerequisites
* Python 3.8+
* Node.js (only for running frontend tests)
### Backend Setup
1. Install Python dependencies:
```bash
pip install -r requirements.txt
```
### Running in Standalone Mode
You can run the manager independently of ComfyUI for development or management purposes.
```bash
python standalone.py --port 8188
```
### Running in ComfyUI
Ensure the folder is located in `ComfyUI/custom_nodes/`. ComfyUI will automatically load it upon startup.
## Testing
### Backend Tests (Pytest)
1. Install development dependencies:
```bash
pip install -r requirements-dev.txt
```
2. Run tests:
```bash
pytest
```
* Coverage reports are generated in `coverage/backend/`.
### Frontend Tests (Vitest)
1. Install Node dependencies:
```bash
npm install
```
2. Run tests:
```bash
npm run test
```
3. Run coverage:
```bash
npm run test:coverage
```
## Development Conventions
* **Python Style:** Follow PEP 8. Use snake_case for files/functions and PascalCase for classes.
* **Frontend:** Standard ES modules. UI components often end in `_widget.js`.
* **Configuration:** User settings are stored in `settings.json`. Developers should reference `settings.json.example`.
* **Localization:** Update `locales/<lang>.json` and run `scripts/sync_translation_keys.py` when changing UI text.
* **Documentation:** Architecture details are in `docs/architecture/` and `IFLOW.md`.

View File

@@ -61,6 +61,37 @@ class ModelPageView:
self._settings = settings_service
self._server_i18n = server_i18n
self._logger = logger
self._app_version = self._get_app_version()
def _get_app_version(self) -> str:
version = "1.0.0"
short_hash = "stable"
try:
import toml
current_file = os.path.abspath(__file__)
# Navigate up from py/routes/handlers/model_handlers.py to project root
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file))))
pyproject_path = os.path.join(root_dir, 'pyproject.toml')
if os.path.exists(pyproject_path):
with open(pyproject_path, 'r', encoding='utf-8') as f:
data = toml.load(f)
version = data.get('project', {}).get('version', '1.0.0').replace('v', '')
# Try to get git info for granular cache busting
git_dir = os.path.join(root_dir, '.git')
if os.path.exists(git_dir):
try:
import git
repo = git.Repo(root_dir)
short_hash = repo.head.commit.hexsha[:7]
except Exception:
# Fallback if git is not available or not a repo
pass
except Exception as e:
self._logger.debug(f"Failed to read version info for cache busting: {e}")
return f"{version}-{short_hash}"
async def handle(self, request: web.Request) -> web.Response:
try:
@@ -96,6 +127,7 @@ class ModelPageView:
"request": request,
"folders": [],
"t": self._server_i18n.get_translation,
"version": self._app_version,
}
if not is_initializing:

View File

@@ -84,10 +84,7 @@ export class AppCore {
// Start onboarding if needed (after everything is initialized)
setTimeout(() => {
// Do not show onboarding if version-mismatch banner is visible
if (!bannerService.isBannerVisible('version-mismatch')) {
onboardingManager.start();
}
onboardingManager.start();
}, 1000); // Small delay to ensure all elements are rendered
// Return the core instance for chaining

View File

@@ -17,7 +17,7 @@ const AFDIAN_URL = 'https://afdian.com/a/pixelpawsai';
const BANNER_HISTORY_KEY = 'banner_history';
const BANNER_HISTORY_VIEWED_AT_KEY = 'banner_history_viewed_at';
const BANNER_HISTORY_LIMIT = 20;
const HISTORY_EXCLUDED_IDS = new Set(['version-mismatch']);
const HISTORY_EXCLUDED_IDS = new Set([]);
/**
* Banner Service for managing notification banners

View File

@@ -4,8 +4,7 @@ import {
setStorageItem,
getStoredVersionInfo,
setStoredVersionInfo,
isVersionMatch,
resetDismissedBanner
isVersionMatch
} from '../utils/storageHelpers.js';
import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -753,94 +752,14 @@ export class UpdateService {
stored: getStoredVersionInfo()
});
// Reset dismissed status for version mismatch banner
resetDismissedBanner('version-mismatch');
// Register and show the version mismatch banner
this.registerVersionMismatchBanner();
// Silently update stored version info as cache busting handles the resource updates
setStoredVersionInfo(this.currentVersionInfo);
}
}
} catch (error) {
console.error('Failed to check version info:', error);
}
}
registerVersionMismatchBanner() {
// Get stored and current version for display
const storedVersion = getStoredVersionInfo() || translate('common.status.unknown');
const currentVersion = this.currentVersionInfo || translate('common.status.unknown');
bannerService.registerBanner('version-mismatch', {
id: 'version-mismatch',
title: translate('banners.versionMismatch.title', {}, 'Application Update Detected'),
content: translate('banners.versionMismatch.content', {
storedVersion,
currentVersion
}, `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`),
actions: [
{
text: translate('banners.versionMismatch.refreshNow', {}, 'Refresh Now'),
icon: 'fas fa-sync',
action: 'hardRefresh',
type: 'primary'
}
],
dismissible: false,
priority: 10,
countdown: 15,
onRegister: (bannerElement) => {
// Add countdown element
const countdownEl = document.createElement('div');
countdownEl.className = 'banner-countdown';
countdownEl.innerHTML = `<span>${translate('banners.versionMismatch.refreshingIn', {}, 'Refreshing in')} <strong>15</strong> ${translate('banners.versionMismatch.seconds', {}, 'seconds')}...</span>`;
bannerElement.querySelector('.banner-content').appendChild(countdownEl);
// Start countdown
let seconds = 15;
const countdownInterval = setInterval(() => {
seconds--;
const strongEl = countdownEl.querySelector('strong');
if (strongEl) strongEl.textContent = seconds;
if (seconds <= 0) {
clearInterval(countdownInterval);
this.performHardRefresh();
}
}, 1000);
// Store interval ID for cleanup
bannerElement.dataset.countdownInterval = countdownInterval;
// Add action button event handler
const actionBtn = bannerElement.querySelector('.banner-action[data-action="hardRefresh"]');
if (actionBtn) {
actionBtn.addEventListener('click', (e) => {
e.preventDefault();
clearInterval(countdownInterval);
this.performHardRefresh();
});
}
},
onRemove: (bannerElement) => {
// Clear any existing interval
const intervalId = bannerElement.dataset.countdownInterval;
if (intervalId) {
clearInterval(parseInt(intervalId));
}
}
});
}
performHardRefresh() {
// Update stored version info before refreshing
setStoredVersionInfo(this.currentVersionInfo);
// Force a hard refresh by adding cache-busting parameter
const cacheBuster = new Date().getTime();
window.location.href = window.location.pathname +
(window.location.search ? window.location.search + '&' : '?') +
`cache=${cacheBuster}`;
}
}
// Create and export singleton instance

View File

@@ -4,8 +4,8 @@
<head>
<title>{% block title %}{{ t('header.appTitle') }}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/loras_static/css/style.css">
<link rel="stylesheet" href="/loras_static/css/onboarding.css">
<link rel="stylesheet" href="/loras_static/css/style.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/onboarding.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/vendor/flag-icons/flag-icons.min.css">
{% block page_css %}{% endblock %}
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
@@ -97,7 +97,7 @@
{% if is_initializing %}
<!-- Load initialization JavaScript -->
<script type="module" src="/loras_static/js/components/initialization.js"></script>
<script type="module" src="/loras_static/js/components/initialization.js?v={{ version }}"></script>
{% else %}
{% block main_script %}{% endblock %}
{% endif %}

View File

@@ -40,5 +40,5 @@
{% endblock %}
{% block main_script %}
<script type="module" src="/loras_static/js/checkpoints.js"></script>
<script type="module" src="/loras_static/js/checkpoints.js?v={{ version }}"></script>
{% endblock %}

View File

@@ -40,5 +40,5 @@
{% endblock %}
{% block main_script %}
<script type="module" src="/loras_static/js/embeddings.js"></script>
<script type="module" src="/loras_static/js/embeddings.js?v={{ version }}"></script>
{% endblock %}

View File

@@ -24,6 +24,6 @@
{% block main_script %}
{% if not is_initializing %}
<script type="module" src="/loras_static/js/loras.js"></script>
<script type="module" src="/loras_static/js/loras.js?v={{ version }}"></script>
{% endif %}
{% endblock %}

View File

@@ -4,9 +4,9 @@
{% block page_id %}recipes{% endblock %}
{% block page_css %}
<link rel="stylesheet" href="/loras_static/css/components/card.css">
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css">
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css">
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
{% endblock %}
{% block additional_components %}
@@ -84,5 +84,5 @@
{% endblock %}
{% block main_script %}
<script type="module" src="/loras_static/js/recipes.js"></script>
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
{% endblock %}

View File

@@ -192,6 +192,6 @@
{% block main_script %}
{% if not is_initializing %}
<script type="module" src="/loras_static/js/statistics.js"></script>
<script type="module" src="/loras_static/js/statistics.js?v={{ version }}"></script>
{% endif %}
{% endblock %}

View File

@@ -234,7 +234,6 @@ describe('AppCore initialization flow', () => {
await vi.runAllTimersAsync();
expect(onboardingManager.start).toHaveBeenCalledTimes(1);
expect(bannerService.isBannerVisible).toHaveBeenCalledWith('version-mismatch');
});
it('does not reinitialize once initialized', async () => {
@@ -262,13 +261,4 @@ describe('AppCore initialization flow', () => {
expect(BulkContextMenu).not.toHaveBeenCalled();
expect(bulkManager.setBulkContextMenu).not.toHaveBeenCalled();
});
it('suppresses onboarding when version mismatch banner is visible', async () => {
bannerService.isBannerVisible.mockReturnValueOnce(true);
await appCore.initialize();
await vi.runAllTimersAsync();
expect(onboardingManager.start).not.toHaveBeenCalled();
});
});