feat(randomizer): add lora pool Vue widget

This commit is contained in:
Will Miao
2026-01-11 16:26:38 +08:00
parent 32249d1886
commit 3d348900ac
26 changed files with 4658 additions and 119 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
__pycache__/
.pytest_cache/
settings.json
path_mappings.yaml
output/*

View File

@@ -9,6 +9,7 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
from .py.nodes.demo_vue_widget_node import LoraManagerDemoNode
from .py.nodes.lora_pool import LoraPoolNode
from .py.metadata_collector import init as init_metadata_collector
except ImportError: # pragma: no cover - allows running under pytest without package install
import importlib
@@ -30,6 +31,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
WanVideoLoraSelectLM = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelectLM
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
LoraManagerDemoNode = importlib.import_module("py.nodes.demo_vue_widget_node").LoraManagerDemoNode
LoraPoolNode = importlib.import_module("py.nodes.lora_pool").LoraPoolNode
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
@@ -42,7 +44,8 @@ NODE_CLASS_MAPPINGS = {
DebugMetadata.NAME: DebugMetadata,
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
"LoraManagerDemoNode": LoraManagerDemoNode
"LoraManagerDemoNode": LoraManagerDemoNode,
LoraPoolNode.NAME: LoraPoolNode
}
WEB_DIRECTORY = "./web/comfyui"

98
py/nodes/lora_pool.py Normal file
View File

@@ -0,0 +1,98 @@
"""
LoRA Pool Node - Defines filter configuration for LoRA selection.
This node provides a visual filter editor that generates a LORA_POOL_CONFIG
object for use by downstream nodes (like LoRA Randomizer).
"""
import logging
logger = logging.getLogger(__name__)
class LoraPoolNode:
"""
A node that defines LoRA filter criteria through a Vue-based widget.
Outputs a LORA_POOL_CONFIG that can be consumed by:
- Frontend: LoRA Randomizer widget reads connected pool's widget value
- Backend: LoRA Randomizer receives config during workflow execution
"""
NAME = "Lora Pool (LoraManager)"
CATEGORY = "Lora Manager/pools"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pool_config": ("LORA_POOL_CONFIG", {}),
},
"hidden": {
# Hidden input to pass through unique node ID for frontend
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("LORA_POOL_CONFIG",)
RETURN_NAMES = ("pool_config",)
FUNCTION = "process"
OUTPUT_NODE = False
def process(self, pool_config, unique_id=None):
"""
Pass through the pool configuration.
The config is generated entirely by the frontend widget.
This function validates and passes through the configuration.
Args:
pool_config: Dict containing filter criteria from widget
unique_id: Node's unique ID (hidden)
Returns:
Tuple containing the validated pool_config
"""
# Validate required structure
if not isinstance(pool_config, dict):
logger.warning("Invalid pool_config type, using empty config")
pool_config = self._default_config()
# Ensure version field exists
if "version" not in pool_config:
pool_config["version"] = 1
# Log for debugging
logger.debug(f"[LoraPoolNode] Processing config: {pool_config}")
return (pool_config,)
@staticmethod
def _default_config():
"""Return default empty configuration."""
return {
"version": 1,
"filters": {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folder": {"path": None, "recursive": True},
"favoritesOnly": False,
"license": {
"noCreditRequired": None,
"allowSellingGeneratedContent": None
}
},
"preview": {"matchCount": 0, "lastUpdated": 0}
}
# Node class mappings for ComfyUI
NODE_CLASS_MAPPINGS = {
"LoraPoolNode": LoraPoolNode
}
# Display name mappings
NODE_DISPLAY_NAME_MAPPINGS = {
"LoraPoolNode": "LoRA Pool (Filter)"
}

View File

@@ -18,6 +18,7 @@
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vue-tsc": "^2.1.10"
}
},
@@ -1612,6 +1613,16 @@
}
}
},
"node_modules/vite-plugin-css-injected-by-js": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/vite-plugin-css-injected-by-js/-/vite-plugin-css-injected-by-js-3.5.2.tgz",
"integrity": "sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"vite": ">2.0.0-0"
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",

View File

@@ -4,9 +4,9 @@
"type": "module",
"description": "Vue-based custom widgets for ComfyUI LoRA Manager",
"dependencies": {
"primevue": "^4.2.5",
"vue": "^3.5.13",
"vue-i18n": "^9.14.0",
"primevue": "^4.2.5"
"vue-i18n": "^9.14.0"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.35.4",
@@ -14,6 +14,7 @@
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vue-tsc": "^2.1.10"
},
"scripts": {

View File

@@ -0,0 +1,113 @@
<template>
<div class="lora-pool-widget">
<!-- Summary View -->
<LoraPoolSummaryView
:selected-base-models="state.selectedBaseModels.value"
:available-base-models="state.availableBaseModels.value"
:include-tags="state.includeTags.value"
:exclude-tags="state.excludeTags.value"
:include-folders="state.includeFolders.value"
:exclude-folders="state.excludeFolders.value"
:no-credit-required="state.noCreditRequired.value"
:allow-selling="state.allowSelling.value"
:preview-items="state.previewItems.value"
:match-count="state.matchCount.value"
:is-loading="state.isLoading.value"
@open-modal="openModal"
@update:include-folders="state.includeFolders.value = $event"
@update:exclude-folders="state.excludeFolders.value = $event"
@update:no-credit-required="state.noCreditRequired.value = $event"
@update:allow-selling="state.allowSelling.value = $event"
@refresh="state.refreshPreview"
/>
<!-- Modals -->
<BaseModelModal
:visible="modalState.isModalOpen('baseModels')"
:models="state.availableBaseModels.value"
:selected="state.selectedBaseModels.value"
@close="modalState.closeModal"
@update:selected="state.selectedBaseModels.value = $event"
/>
<TagsModal
:visible="modalState.isModalOpen('includeTags')"
:tags="state.availableTags.value"
:selected="state.includeTags.value"
variant="include"
@close="modalState.closeModal"
@update:selected="state.includeTags.value = $event"
/>
<TagsModal
:visible="modalState.isModalOpen('excludeTags')"
:tags="state.availableTags.value"
:selected="state.excludeTags.value"
variant="exclude"
@close="modalState.closeModal"
@update:selected="state.excludeTags.value = $event"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import LoraPoolSummaryView from './lora-pool/LoraPoolSummaryView.vue'
import BaseModelModal from './lora-pool/modals/BaseModelModal.vue'
import TagsModal from './lora-pool/modals/TagsModal.vue'
import { useLoraPoolState } from '../composables/useLoraPoolState'
import { useModalState, type ModalType } from '../composables/useModalState'
import type { ComponentWidget, LoraPoolConfig, LegacyLoraPoolConfig } from '../composables/types'
// Props
const props = defineProps<{
widget: ComponentWidget
node: { id: number }
}>()
// State management
const state = useLoraPoolState(props.widget)
const modalState = useModalState()
// Modal handling
const openModal = (modal: ModalType) => {
modalState.openModal(modal)
}
// Lifecycle
onMounted(async () => {
console.log('[LoraPoolWidget] Mounted, node ID:', props.node.id)
// Setup serialization
props.widget.serializeValue = async () => {
const config = state.buildConfig()
console.log('[LoraPoolWidget] Serializing config:', config)
return config
}
// Restore from saved value
if (props.widget.value) {
console.log('[LoraPoolWidget] Restoring from saved value:', props.widget.value)
state.restoreFromConfig(props.widget.value as LoraPoolConfig | LegacyLoraPoolConfig)
}
// Fetch filter options
await state.fetchFilterOptions()
// Initial preview
await state.refreshPreview()
})
</script>
<style scoped>
.lora-pool-widget {
padding: 12px;
background: var(--comfy-menu-bg, #1a1a1a);
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="preview">
<div class="preview__header">
<span class="preview__title">Matching LoRAs: {{ matchCount }}</span>
<button
type="button"
class="preview__refresh"
:class="{ 'preview__refresh--loading': isLoading }"
@click="$emit('refresh')"
:disabled="isLoading"
>
<svg class="preview__refresh-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
</svg>
</button>
</div>
<div v-if="items.length > 0" class="preview__list">
<div
v-for="item in items.slice(0, 5)"
:key="item.file_path"
class="preview__item"
>
<img
v-if="item.preview_url"
:src="item.preview_url"
class="preview__thumb"
@error="onImageError"
/>
<div v-else class="preview__thumb preview__thumb--placeholder">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
</svg>
</div>
<span class="preview__name">{{ item.model_name || item.file_name }}</span>
</div>
<div v-if="matchCount > 5" class="preview__more">
+{{ matchCount - 5 }} more
</div>
</div>
<div v-else-if="!isLoading" class="preview__empty">
No matching LoRAs
</div>
<div v-else class="preview__loading">
Loading...
</div>
</div>
</template>
<script setup lang="ts">
import type { LoraItem } from '../../composables/types'
defineProps<{
items: LoraItem[]
matchCount: number
isLoading: boolean
}>()
defineEmits<{
refresh: []
}>()
const onImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
</script>
<style scoped>
.preview {
padding-top: 12px;
border-top: 1px solid var(--border-color, #444);
}
.preview__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.preview__title {
font-size: 12px;
font-weight: 500;
color: var(--fg-color, #fff);
}
.preview__refresh {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--fg-color, #fff);
cursor: pointer;
opacity: 0.6;
border-radius: 4px;
transition: all 0.15s;
}
.preview__refresh:hover {
opacity: 1;
background: var(--comfy-input-bg, #333);
}
.preview__refresh:disabled {
cursor: not-allowed;
}
.preview__refresh-icon {
width: 14px;
height: 14px;
}
.preview__refresh--loading .preview__refresh-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.preview__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.preview__item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
}
.preview__thumb {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 3px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.2);
}
.preview__thumb--placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-color, #fff);
opacity: 0.2;
}
.preview__thumb--placeholder svg {
width: 14px;
height: 14px;
}
.preview__name {
flex: 1;
font-size: 11px;
color: var(--fg-color, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview__more {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.5;
text-align: center;
padding: 6px;
}
.preview__empty,
.preview__loading {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.4;
text-align: center;
padding: 16px;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="summary-view">
<!-- Header with filter count badge -->
<div class="summary-view__header">
<div class="summary-view__badge">
<svg class="summary-view__badge-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/>
</svg>
<span class="summary-view__count">{{ matchCount.toLocaleString() }}</span>
</div>
</div>
<!-- Filter sections -->
<div class="summary-view__filters">
<BaseModelSection
:selected="selectedBaseModels"
:models="availableBaseModels"
@edit="$emit('open-modal', 'baseModels')"
/>
<TagsSection
:include-tags="includeTags"
:exclude-tags="excludeTags"
@edit-include="$emit('open-modal', 'includeTags')"
@edit-exclude="$emit('open-modal', 'excludeTags')"
/>
<FoldersSection
:include-folders="includeFolders"
:exclude-folders="excludeFolders"
@update:include-folders="$emit('update:includeFolders', $event)"
@update:exclude-folders="$emit('update:excludeFolders', $event)"
/>
<LicenseSection
:no-credit-required="noCreditRequired"
:allow-selling="allowSelling"
@update:no-credit-required="$emit('update:noCreditRequired', $event)"
@update:allow-selling="$emit('update:allowSelling', $event)"
/>
</div>
<!-- Preview -->
<LoraPoolPreview
:items="previewItems"
:match-count="matchCount"
:is-loading="isLoading"
@refresh="$emit('refresh')"
/>
</div>
</template>
<script setup lang="ts">
import BaseModelSection from './sections/BaseModelSection.vue'
import TagsSection from './sections/TagsSection.vue'
import FoldersSection from './sections/FoldersSection.vue'
import LicenseSection from './sections/LicenseSection.vue'
import LoraPoolPreview from './LoraPoolPreview.vue'
import type { BaseModelOption, LoraItem } from '../../composables/types'
import type { ModalType } from '../../composables/useModalState'
defineProps<{
// Base models
selectedBaseModels: string[]
availableBaseModels: BaseModelOption[]
// Tags
includeTags: string[]
excludeTags: string[]
// Folders
includeFolders: string[]
excludeFolders: string[]
// License
noCreditRequired: boolean
allowSelling: boolean
// Preview
previewItems: LoraItem[]
matchCount: number
isLoading: boolean
}>()
defineEmits<{
'open-modal': [modal: ModalType]
'update:includeFolders': [value: string[]]
'update:excludeFolders': [value: string[]]
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
refresh: []
}>()
</script>
<style scoped>
.summary-view {
display: flex;
flex-direction: column;
height: 100%;
}
.summary-view__header {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.summary-view__badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.4);
border-radius: 4px;
color: #3b82f6;
}
.summary-view__badge-icon {
width: 12px;
height: 12px;
}
.summary-view__count {
font-size: 12px;
font-weight: 600;
}
.summary-view__filters {
flex: 1;
overflow-y: auto;
padding-right: 4px;
margin-right: -4px;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<ModalWrapper
:visible="visible"
title="Select Base Models"
subtitle="Choose which base models to include in your filter"
@close="$emit('close')"
>
<template #search>
<div class="search-container">
<svg class="search-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Search models..."
@input="onSearch"
/>
</div>
</template>
<div class="model-list">
<label
v-for="model in filteredModels"
:key="model.name"
class="model-item"
>
<input
type="checkbox"
:checked="isSelected(model.name)"
@change="toggleModel(model.name)"
class="model-checkbox"
/>
<span class="model-checkbox-visual">
<svg v-if="isSelected(model.name)" class="check-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>
</span>
<span class="model-name">{{ model.name }}</span>
<span class="model-count">({{ model.count }})</span>
</label>
<div v-if="filteredModels.length === 0" class="no-results">
No models found
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ModalWrapper from './ModalWrapper.vue'
import type { BaseModelOption } from '../../../composables/types'
const props = defineProps<{
visible: boolean
models: BaseModelOption[]
selected: string[]
}>()
const emit = defineEmits<{
close: []
'update:selected': [value: string[]]
}>()
const searchQuery = ref('')
const filteredModels = computed(() => {
if (!searchQuery.value) {
return props.models
}
const query = searchQuery.value.toLowerCase()
return props.models.filter(m => m.name.toLowerCase().includes(query))
})
const isSelected = (name: string) => {
return props.selected.includes(name)
}
const toggleModel = (name: string) => {
const newSelected = isSelected(name)
? props.selected.filter(n => n !== name)
: [...props.selected, name]
emit('update:selected', newSelected)
}
const onSearch = () => {
// Debounce handled by v-model reactivity
}
</script>
<style scoped>
.search-container {
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 32px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
color: var(--fg-color, #fff);
font-size: 13px;
outline: none;
}
.search-input:focus {
border-color: var(--fg-color, #fff);
}
.search-input::placeholder {
color: var(--fg-color, #fff);
opacity: 0.4;
}
.model-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.model-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.model-item:hover {
background: var(--comfy-input-bg, #333);
}
.model-checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
}
.model-checkbox-visual {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #555);
border-radius: 4px;
flex-shrink: 0;
transition: all 0.15s;
}
.model-item:hover .model-checkbox-visual {
border-color: var(--fg-color, #fff);
}
.model-checkbox:checked + .model-checkbox-visual {
background: var(--fg-color, #fff);
border-color: var(--fg-color, #fff);
}
.check-icon {
width: 12px;
height: 12px;
color: var(--comfy-menu-bg, #1a1a1a);
}
.model-name {
flex: 1;
font-size: 13px;
color: var(--fg-color, #fff);
}
.model-count {
font-size: 12px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.no-results {
padding: 20px;
text-align: center;
color: var(--fg-color, #fff);
opacity: 0.5;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="visible"
class="lora-pool-modal-backdrop"
@click.self="close"
@keydown.esc="close"
>
<div class="lora-pool-modal" :class="modalClass" role="dialog" aria-modal="true">
<div class="lora-pool-modal__header">
<div class="lora-pool-modal__title-container">
<h3 class="lora-pool-modal__title">{{ title }}</h3>
<p v-if="subtitle" class="lora-pool-modal__subtitle">{{ subtitle }}</p>
</div>
<button
class="lora-pool-modal__close"
@click="close"
type="button"
aria-label="Close"
>
&times;
</button>
</div>
<div v-if="$slots.search" class="lora-pool-modal__search">
<slot name="search"></slot>
</div>
<div class="lora-pool-modal__body">
<slot></slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
visible: boolean
title: string
subtitle?: string
modalClass?: string
}>()
const emit = defineEmits<{
close: []
}>()
const close = () => {
emit('close')
}
// Handle escape key globally
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.visible) {
close()
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// Prevent body scroll when modal is open
watch(() => props.visible, (isVisible) => {
if (isVisible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
</script>
<style scoped>
.lora-pool-modal-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.lora-pool-modal {
background: var(--comfy-menu-bg, #1a1a1a);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
max-width: 400px;
width: 90%;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.lora-pool-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color, #444);
}
.lora-pool-modal__title-container {
flex: 1;
}
.lora-pool-modal__title {
font-size: 16px;
font-weight: 600;
color: var(--fg-color, #fff);
margin: 0;
}
.lora-pool-modal__subtitle {
font-size: 12px;
color: var(--fg-color, #fff);
opacity: 0.6;
margin: 4px 0 0 0;
}
.lora-pool-modal__close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--fg-color, #fff);
font-size: 22px;
cursor: pointer;
opacity: 0.7;
border-radius: 4px;
line-height: 1;
padding: 0;
margin: -4px -4px 0 0;
}
.lora-pool-modal__close:hover {
opacity: 1;
background: var(--comfy-input-bg, #333);
}
.lora-pool-modal__search {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #444);
}
.lora-pool-modal__body {
flex: 1;
overflow-y: auto;
padding: 12px 16px 16px;
}
/* Transitions */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .lora-pool-modal,
.modal-leave-active .lora-pool-modal {
transition: transform 0.2s ease;
}
.modal-enter-from .lora-pool-modal,
.modal-leave-to .lora-pool-modal {
transform: scale(0.95);
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<ModalWrapper
:visible="visible"
:title="title"
:subtitle="subtitle"
:modal-class="variant === 'exclude' ? 'tags-modal--exclude' : 'tags-modal--include'"
@close="$emit('close')"
>
<template #search>
<div class="search-container">
<svg class="search-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Search tags..."
/>
</div>
</template>
<div class="tags-container">
<button
v-for="tag in filteredTags"
:key="tag.tag"
type="button"
class="tag-chip"
:class="{ 'tag-chip--selected': isSelected(tag.tag) }"
@click="toggleTag(tag.tag)"
>
{{ tag.tag }}
</button>
<div v-if="filteredTags.length === 0" class="no-results">
No tags found
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ModalWrapper from './ModalWrapper.vue'
import type { TagOption } from '../../../composables/types'
const props = defineProps<{
visible: boolean
tags: TagOption[]
selected: string[]
variant: 'include' | 'exclude'
}>()
const emit = defineEmits<{
close: []
'update:selected': [value: string[]]
}>()
const title = computed(() =>
props.variant === 'include' ? 'Include Tags' : 'Exclude Tags'
)
const subtitle = computed(() =>
props.variant === 'include'
? 'Select tags that items must have'
: 'Select tags that items must NOT have'
)
const searchQuery = ref('')
const filteredTags = computed(() => {
if (!searchQuery.value) {
return props.tags
}
const query = searchQuery.value.toLowerCase()
return props.tags.filter(t => t.tag.toLowerCase().includes(query))
})
const isSelected = (tag: string) => {
return props.selected.includes(tag)
}
const toggleTag = (tag: string) => {
const newSelected = isSelected(tag)
? props.selected.filter(t => t !== tag)
: [...props.selected, tag]
emit('update:selected', newSelected)
}
</script>
<style scoped>
.search-container {
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 32px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
color: var(--fg-color, #fff);
font-size: 13px;
outline: none;
}
.search-input:focus {
border-color: var(--fg-color, #fff);
}
.search-input::placeholder {
color: var(--fg-color, #fff);
opacity: 0.4;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-chip {
padding: 6px 12px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #555);
border-radius: 16px;
color: var(--fg-color, #fff);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.tag-chip:hover {
border-color: var(--fg-color, #fff);
}
/* Include variant - green when selected */
.tags-modal--include .tag-chip--selected,
.tag-chip--selected {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.6);
color: #22c55e;
}
/* Exclude variant - red when selected */
.tags-modal--exclude .tag-chip--selected {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6);
color: #ef4444;
}
.no-results {
width: 100%;
padding: 20px;
text-align: center;
color: var(--fg-color, #fff);
opacity: 0.5;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">BASE MODEL</span>
<EditButton @click="$emit('edit')" />
</div>
<div class="section__content">
<div v-if="selected.length === 0" class="section__placeholder">
All models
</div>
<div v-else class="section__chips">
<FilterChip
v-for="name in selected"
:key="name"
:label="name"
:count="getCount(name)"
variant="neutral"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import FilterChip from '../shared/FilterChip.vue'
import EditButton from '../shared/EditButton.vue'
import type { BaseModelOption } from '../../../composables/types'
const props = defineProps<{
selected: string[]
models: BaseModelOption[]
}>()
defineEmits<{
edit: []
}>()
const getCount = (name: string) => {
const model = props.models.find(m => m.name === name)
return model?.count
}
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__content {
min-height: 32px;
display: flex;
align-items: center;
}
.section__placeholder {
width: 100%;
padding: 8px 12px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
font-size: 12px;
color: var(--fg-color, #fff);
opacity: 0.5;
text-align: center;
}
.section__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">FOLDERS</span>
</div>
<div class="section__columns">
<!-- Include column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--include">INCLUDE</span>
</div>
<div class="section__input-row">
<input
v-model="includeInput"
type="text"
class="section__input"
placeholder="Path..."
@keydown.enter="addInclude"
/>
<button
type="button"
class="section__add-btn section__add-btn--include"
@click="addInclude"
>
+
</button>
</div>
<div v-if="includeFolders.length > 0" class="section__paths">
<FilterChip
v-for="path in includeFolders"
:key="path"
:label="truncatePath(path)"
variant="path"
removable
@remove="removeInclude(path)"
/>
</div>
</div>
<!-- Exclude column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
</div>
<div class="section__input-row">
<input
v-model="excludeInput"
type="text"
class="section__input"
placeholder="Path..."
@keydown.enter="addExclude"
/>
<button
type="button"
class="section__add-btn section__add-btn--exclude"
@click="addExclude"
>
+
</button>
</div>
<div v-if="excludeFolders.length > 0" class="section__paths">
<FilterChip
v-for="path in excludeFolders"
:key="path"
:label="truncatePath(path)"
variant="path"
removable
@remove="removeExclude(path)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FilterChip from '../shared/FilterChip.vue'
const props = defineProps<{
includeFolders: string[]
excludeFolders: string[]
}>()
const emit = defineEmits<{
'update:includeFolders': [value: string[]]
'update:excludeFolders': [value: string[]]
}>()
const includeInput = ref('')
const excludeInput = ref('')
const truncatePath = (path: string) => {
if (path.length <= 20) return path
return '...' + path.slice(-17)
}
const addInclude = () => {
const path = includeInput.value.trim()
if (path && !props.includeFolders.includes(path)) {
emit('update:includeFolders', [...props.includeFolders, path])
includeInput.value = ''
}
}
const removeInclude = (path: string) => {
emit('update:includeFolders', props.includeFolders.filter(p => p !== path))
}
const addExclude = () => {
const path = excludeInput.value.trim()
if (path && !props.excludeFolders.includes(path)) {
emit('update:excludeFolders', [...props.excludeFolders, path])
excludeInput.value = ''
}
}
const removeExclude = (path: string) => {
emit('update:excludeFolders', props.excludeFolders.filter(p => p !== path))
}
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column {
min-width: 0;
}
.section__column-header {
margin-bottom: 6px;
}
.section__column-title {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include {
color: #22c55e;
}
.section__column-title--exclude {
color: #ef4444;
}
.section__input-row {
display: flex;
gap: 4px;
}
.section__input {
flex: 1;
min-width: 0;
padding: 6px 8px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 11px;
outline: none;
}
.section__input:focus {
border-color: var(--fg-color, #fff);
}
.section__input::placeholder {
color: var(--fg-color, #fff);
opacity: 0.4;
}
.section__add-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.section__add-btn:hover {
border-color: var(--fg-color, #fff);
}
.section__add-btn--include:hover {
background: rgba(34, 197, 94, 0.2);
border-color: #22c55e;
color: #22c55e;
}
.section__add-btn--exclude:hover {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
color: #ef4444;
}
.section__paths {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">LICENSE</span>
</div>
<div class="section__toggles">
<label class="toggle-item">
<span class="toggle-item__label">No Credit</span>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': noCreditRequired }"
@click="$emit('update:noCreditRequired', !noCreditRequired)"
role="switch"
:aria-checked="noCreditRequired"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</label>
<label class="toggle-item">
<span class="toggle-item__label">Allow Selling</span>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': allowSelling }"
@click="$emit('update:allowSelling', !allowSelling)"
role="switch"
:aria-checked="allowSelling"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</label>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
noCreditRequired: boolean
allowSelling: boolean
}>()
defineEmits<{
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
}>()
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__toggles {
display: flex;
gap: 16px;
}
.toggle-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle-item__label {
font-size: 12px;
color: var(--fg-color, #fff);
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.toggle-switch__track {
position: absolute;
inset: 0;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 10px;
transition: all 0.2s;
}
.toggle-switch--active .toggle-switch__track {
background: rgba(34, 197, 94, 0.3);
border-color: rgba(34, 197, 94, 0.6);
}
.toggle-switch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
transition: all 0.2s;
opacity: 0.6;
}
.toggle-switch--active .toggle-switch__thumb {
transform: translateX(16px);
background: #22c55e;
opacity: 1;
}
.toggle-switch:hover .toggle-switch__thumb {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">TAGS</span>
</div>
<div class="section__columns">
<!-- Include column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--include">INCLUDE</span>
<EditButton @click="$emit('edit-include')" />
</div>
<div class="section__column-content">
<div v-if="includeTags.length === 0" class="section__empty">
None
</div>
<div v-else class="section__chips">
<FilterChip
v-for="tag in includeTags"
:key="tag"
:label="tag"
variant="include"
/>
</div>
</div>
</div>
<!-- Exclude column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
<EditButton @click="$emit('edit-exclude')" />
</div>
<div class="section__column-content">
<div v-if="excludeTags.length === 0" class="section__empty">
None
</div>
<div v-else class="section__chips">
<FilterChip
v-for="tag in excludeTags"
:key="tag"
:label="tag"
variant="exclude"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import FilterChip from '../shared/FilterChip.vue'
import EditButton from '../shared/EditButton.vue'
defineProps<{
includeTags: string[]
excludeTags: string[]
}>()
defineEmits<{
'edit-include': []
'edit-exclude': []
}>()
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column {
min-width: 0;
}
.section__column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.section__column-title {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include {
color: #22c55e;
}
.section__column-title--exclude {
color: #ef4444;
}
.section__column-content {
min-height: 28px;
}
.section__empty {
padding: 6px 0;
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.4;
}
.section__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<button class="edit-button" type="button" @click="$emit('click')">
<svg class="edit-button__icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
<span class="edit-button__text">Edit</span>
</button>
</template>
<script setup lang="ts">
defineEmits<{
click: []
}>()
</script>
<style scoped>
.edit-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: transparent;
border: none;
color: var(--fg-color);
font-size: 11px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
border-radius: 3px;
}
.edit-button:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.05);
}
.edit-button__icon {
width: 10px;
height: 10px;
}
.edit-button__text {
font-weight: 400;
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<span class="filter-chip" :class="variantClass">
<span class="filter-chip__text">{{ label }}</span>
<span v-if="count !== undefined" class="filter-chip__count">({{ count }})</span>
<button
v-if="removable"
class="filter-chip__remove"
@click.stop="$emit('remove')"
type="button"
>
&times;
</button>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label: string
count?: number
variant?: 'include' | 'exclude' | 'neutral' | 'path'
removable?: boolean
}>()
defineEmits<{
remove: []
}>()
const variantClass = computed(() => {
return props.variant ? `filter-chip--${props.variant}` : ''
})
</script>
<style scoped>
.filter-chip {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: var(--comfy-input-bg);
border: 1px solid var(--border-color);
color: var(--fg-color);
white-space: nowrap;
}
.filter-chip__text {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-chip__count {
opacity: 0.6;
font-size: 10px;
}
.filter-chip__remove {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-left: 2px;
padding: 0;
background: transparent;
border: none;
color: inherit;
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
}
.filter-chip__remove:hover {
opacity: 1;
}
/* Variants */
.filter-chip--include {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #22c55e;
}
.filter-chip--exclude {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.filter-chip--neutral {
background: rgba(100, 100, 100, 0.3);
border-color: rgba(150, 150, 150, 0.4);
color: var(--fg-color);
}
.filter-chip--path {
background: rgba(30, 30, 30, 0.8);
border-color: rgba(255, 255, 255, 0.15);
color: var(--fg-color);
font-family: monospace;
font-size: 10px;
}
</style>

View File

@@ -0,0 +1,59 @@
// Shared types for LoRA Pool Widget
export interface LoraPoolConfig {
version: number
filters: {
baseModels: string[]
tags: { include: string[]; exclude: string[] }
folders: { include: string[]; exclude: string[] }
license: {
noCreditRequired: boolean
allowSelling: boolean
}
}
preview: { matchCount: number; lastUpdated: number }
}
export interface LoraItem {
file_path: string
file_name: string
model_name?: string
preview_url?: string
}
export interface BaseModelOption {
name: string
count: number
}
export interface TagOption {
tag: string
count: number
}
export interface FolderTreeNode {
key: string
label: string
children?: FolderTreeNode[]
}
export interface ComponentWidget {
serializeValue?: () => Promise<LoraPoolConfig>
value?: LoraPoolConfig
}
// Legacy config for migration (v1)
export interface LegacyLoraPoolConfig {
version: 1
filters: {
baseModels: string[]
tags: { include: string[]; exclude: string[] }
folder: { path: string | null; recursive: boolean }
favoritesOnly: boolean
license: {
noCreditRequired: boolean | null
allowSellingGeneratedContent: boolean | null
}
}
preview: { matchCount: number; lastUpdated: number }
}

View File

@@ -0,0 +1,116 @@
import { ref } from 'vue'
import type { BaseModelOption, TagOption, FolderTreeNode, LoraItem } from './types'
export function useLoraPoolApi() {
const isLoading = ref(false)
const fetchBaseModels = async (limit = 50): Promise<BaseModelOption[]> => {
try {
const response = await fetch(`/api/lm/loras/base-models?limit=${limit}`)
const data = await response.json()
return data.base_models || []
} catch (error) {
console.error('[LoraPoolApi] Failed to fetch base models:', error)
return []
}
}
const fetchTags = async (limit = 100): Promise<TagOption[]> => {
try {
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`)
const data = await response.json()
return data.tags || []
} catch (error) {
console.error('[LoraPoolApi] Failed to fetch tags:', error)
return []
}
}
const fetchFolderTree = async (): Promise<FolderTreeNode[]> => {
try {
const response = await fetch('/api/lm/loras/unified-folder-tree')
const data = await response.json()
return transformFolderTree(data.tree || {})
} catch (error) {
console.error('[LoraPoolApi] Failed to fetch folder tree:', error)
return []
}
}
const transformFolderTree = (tree: Record<string, any>, parentPath = ''): FolderTreeNode[] => {
if (!tree || typeof tree !== 'object') {
return []
}
return Object.entries(tree).map(([name, children]) => {
const path = parentPath ? `${parentPath}/${name}` : name
const childNodes = transformFolderTree(children as Record<string, any>, path)
return {
key: path,
label: name,
children: childNodes.length > 0 ? childNodes : undefined
}
})
}
interface FetchLorasParams {
baseModels?: string[]
tagsInclude?: string[]
tagsExclude?: string[]
foldersInclude?: string[]
foldersExclude?: string[]
noCreditRequired?: boolean
allowSelling?: boolean
page?: number
pageSize?: number
}
const fetchLoras = async (params: FetchLorasParams): Promise<{ items: LoraItem[]; total: number }> => {
isLoading.value = true
try {
const urlParams = new URLSearchParams()
urlParams.set('page', String(params.page || 1))
urlParams.set('page_size', String(params.pageSize || 6))
params.baseModels?.forEach(bm => urlParams.append('base_model', bm))
params.tagsInclude?.forEach(tag => urlParams.append('tag_include', tag))
params.tagsExclude?.forEach(tag => urlParams.append('tag_exclude', tag))
// For now, use first include folder (backend currently supports single folder)
if (params.foldersInclude && params.foldersInclude.length > 0) {
urlParams.set('folder', params.foldersInclude[0])
urlParams.set('recursive', 'true')
}
if (params.noCreditRequired !== undefined) {
urlParams.set('credit_required', String(!params.noCreditRequired))
}
if (params.allowSelling !== undefined) {
urlParams.set('allow_selling_generated_content', String(params.allowSelling))
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
const data = await response.json()
return {
items: data.items || [],
total: data.total || 0
}
} catch (error) {
console.error('[LoraPoolApi] Failed to fetch loras:', error)
return { items: [], total: 0 }
} finally {
isLoading.value = false
}
}
return {
isLoading,
fetchBaseModels,
fetchTags,
fetchFolderTree,
fetchLoras
}
}

View File

@@ -0,0 +1,187 @@
import { ref, computed, watch } from 'vue'
import type {
LoraPoolConfig,
LegacyLoraPoolConfig,
BaseModelOption,
TagOption,
FolderTreeNode,
LoraItem,
ComponentWidget
} from './types'
import { useLoraPoolApi } from './useLoraPoolApi'
export function useLoraPoolState(widget: ComponentWidget) {
const api = useLoraPoolApi()
// Filter state
const selectedBaseModels = ref<string[]>([])
const includeTags = ref<string[]>([])
const excludeTags = ref<string[]>([])
const includeFolders = ref<string[]>([])
const excludeFolders = ref<string[]>([])
const noCreditRequired = ref(false)
const allowSelling = ref(false)
// Available options from API
const availableBaseModels = ref<BaseModelOption[]>([])
const availableTags = ref<TagOption[]>([])
const folderTree = ref<FolderTreeNode[]>([])
// Preview state
const previewItems = ref<LoraItem[]>([])
const matchCount = ref(0)
const isLoading = computed(() => api.isLoading.value)
// Build config from current state
const buildConfig = (): LoraPoolConfig => {
const config: LoraPoolConfig = {
version: 2,
filters: {
baseModels: selectedBaseModels.value,
tags: {
include: includeTags.value,
exclude: excludeTags.value
},
folders: {
include: includeFolders.value,
exclude: excludeFolders.value
},
license: {
noCreditRequired: noCreditRequired.value,
allowSelling: allowSelling.value
}
},
preview: {
matchCount: matchCount.value,
lastUpdated: Date.now()
}
}
// Update widget value
widget.value = config
return config
}
// Migrate legacy config (v1) to current format (v2)
const migrateConfig = (legacy: LegacyLoraPoolConfig): LoraPoolConfig => {
return {
version: 2,
filters: {
baseModels: legacy.filters.baseModels || [],
tags: {
include: legacy.filters.tags?.include || [],
exclude: legacy.filters.tags?.exclude || []
},
folders: {
include: legacy.filters.folder?.path ? [legacy.filters.folder.path] : [],
exclude: []
},
license: {
noCreditRequired: legacy.filters.license?.noCreditRequired ?? false,
allowSelling: legacy.filters.license?.allowSellingGeneratedContent ?? false
}
},
preview: legacy.preview || { matchCount: 0, lastUpdated: 0 }
}
}
// Restore state from config
const restoreFromConfig = (rawConfig: LoraPoolConfig | LegacyLoraPoolConfig) => {
// Migrate if needed
const config = rawConfig.version === 1
? migrateConfig(rawConfig as LegacyLoraPoolConfig)
: rawConfig as LoraPoolConfig
if (!config?.filters) return
const { filters, preview } = config
selectedBaseModels.value = filters.baseModels || []
includeTags.value = filters.tags?.include || []
excludeTags.value = filters.tags?.exclude || []
includeFolders.value = filters.folders?.include || []
excludeFolders.value = filters.folders?.exclude || []
noCreditRequired.value = filters.license?.noCreditRequired ?? false
allowSelling.value = filters.license?.allowSelling ?? false
matchCount.value = preview?.matchCount || 0
}
// Fetch filter options from API
const fetchFilterOptions = async () => {
const [baseModels, tags, folders] = await Promise.all([
api.fetchBaseModels(),
api.fetchTags(),
api.fetchFolderTree()
])
availableBaseModels.value = baseModels
availableTags.value = tags
folderTree.value = folders
}
// Refresh preview with current filters
const refreshPreview = async () => {
const result = await api.fetchLoras({
baseModels: selectedBaseModels.value,
tagsInclude: includeTags.value,
tagsExclude: excludeTags.value,
foldersInclude: includeFolders.value,
foldersExclude: excludeFolders.value,
noCreditRequired: noCreditRequired.value || undefined,
allowSelling: allowSelling.value || undefined,
pageSize: 6
})
previewItems.value = result.items
matchCount.value = result.total
buildConfig()
}
// Debounced filter change handler
let filterTimeout: ReturnType<typeof setTimeout> | null = null
const onFilterChange = () => {
if (filterTimeout) clearTimeout(filterTimeout)
filterTimeout = setTimeout(() => {
refreshPreview()
}, 300)
}
// Watch all filter changes
watch([
selectedBaseModels,
includeTags,
excludeTags,
includeFolders,
excludeFolders,
noCreditRequired,
allowSelling
], onFilterChange, { deep: true })
return {
// Filter state
selectedBaseModels,
includeTags,
excludeTags,
includeFolders,
excludeFolders,
noCreditRequired,
allowSelling,
// Available options
availableBaseModels,
availableTags,
folderTree,
// Preview state
previewItems,
matchCount,
isLoading,
// Actions
buildConfig,
restoreFromConfig,
fetchFilterOptions,
refreshPreview
}
}
export type LoraPoolStateReturn = ReturnType<typeof useLoraPoolState>

View File

@@ -0,0 +1,31 @@
import { ref, computed } from 'vue'
export type ModalType = 'baseModels' | 'includeTags' | 'excludeTags' | null
export function useModalState() {
const activeModal = ref<ModalType>(null)
const isOpen = computed(() => activeModal.value !== null)
const openModal = (modal: ModalType) => {
activeModal.value = modal
}
const closeModal = () => {
activeModal.value = null
}
const isModalOpen = (modal: ModalType) => {
return activeModal.value === modal
}
return {
activeModal,
isOpen,
openModal,
closeModal,
isModalOpen
}
}
export type ModalStateReturn = ReturnType<typeof useModalState>

View File

@@ -1,6 +1,7 @@
import { createApp, type App as VueApp } from 'vue'
import PrimeVue from 'primevue/config'
import DemoWidget from '@/components/DemoWidget.vue'
import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
// @ts-ignore - ComfyUI external module
import { app } from '../../../scripts/app.js'
@@ -13,7 +14,6 @@ function createVueWidget(node) {
container.id = `lora-manager-demo-widget-${node.id}`
container.style.width = '100%'
container.style.height = '100%'
container.style.minHeight = '300px'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
@@ -34,7 +34,55 @@ function createVueWidget(node) {
node
})
vueApp.use(PrimeVue)
vueApp.use(PrimeVue, {
unstyled: true,
ripple: false
})
vueApp.mount(container)
vueApps.set(node.id, vueApp)
widget.onRemove = () => {
const vueApp = vueApps.get(node.id)
if (vueApp) {
vueApp.unmount()
vueApps.delete(node.id)
}
}
return { widget }
}
// @ts-ignore
function createLoraPoolWidget(node) {
const container = document.createElement('div')
container.id = `lora-pool-widget-${node.id}`
container.style.width = '100%'
container.style.height = '100%'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
const widget = node.addDOMWidget(
'pool_config',
'LORA_POOL_CONFIG',
container,
{
getMinHeight: () => 680,
hideOnZoom: false,
serialize: true
}
)
const vueApp = createApp(LoraPoolWidget, {
widget,
node
})
vueApp.use(PrimeVue, {
unstyled: true,
ripple: false
})
vueApp.mount(container)
vueApps.set(node.id, vueApp)
@@ -51,23 +99,18 @@ function createVueWidget(node) {
}
app.registerExtension({
name: 'comfyui.loramanager.demo',
name: 'LoraManager.VueWidgets',
getCustomWidgets() {
return {
// @ts-ignore
LORA_DEMO_WIDGET(node) {
return createVueWidget(node)
},
// @ts-ignore
LORA_POOL_CONFIG(node) {
return createLoraPoolWidget(node)
}
}
},
// @ts-ignore
nodeCreated(node) {
if (node.constructor?.comfyClass !== 'LoraManagerDemoNode') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 350), Math.max(oldHeight, 400)])
}
})

View File

@@ -1,9 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
cssInjectedByJsPlugin() // Inject CSS into JS for ComfyUI compatibility
],
resolve: {
alias: {
'@': resolve(__dirname, './src')

View File

@@ -1,56 +0,0 @@
.demo-widget-container[data-v-df0cb94d] {
padding: 12px;
box-sizing: border-box;
background: var(--comfy-menu-bg);
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
}
.demo-title[data-v-df0cb94d] {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: var(--fg-color);
}
.demo-content[data-v-df0cb94d] {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.input-group[data-v-df0cb94d] {
display: flex;
flex-direction: column;
gap: 6px;
}
.input-group label[data-v-df0cb94d] {
font-size: 13px;
font-weight: 500;
color: var(--fg-color);
}
.demo-input[data-v-df0cb94d] {
width: 100%;
}
.button-group[data-v-df0cb94d] {
display: flex;
gap: 8px;
margin-top: 8px;
}
.result-card[data-v-df0cb94d] {
margin-top: 8px;
background: var(--comfy-input-bg);
}
.result-card[data-v-df0cb94d] .p-card-title {
font-size: 14px;
margin-bottom: 8px;
}
.result-card[data-v-df0cb94d] .p-card-content {
padding-top: 0;
}
.result-card p[data-v-df0cb94d] {
margin: 4px 0;
font-size: 13px;
color: var(--fg-color);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long