mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat(randomizer): add lora pool Vue widget
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
settings.json
|
settings.json
|
||||||
path_mappings.yaml
|
path_mappings.yaml
|
||||||
output/*
|
output/*
|
||||||
|
|||||||
@@ -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 import WanVideoLoraSelectLM
|
||||||
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
||||||
from .py.nodes.demo_vue_widget_node import LoraManagerDemoNode
|
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
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except ImportError: # pragma: no cover - allows running under pytest without package install
|
except ImportError: # pragma: no cover - allows running under pytest without package install
|
||||||
import importlib
|
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
|
WanVideoLoraSelectLM = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelectLM
|
||||||
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
|
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
|
||||||
LoraManagerDemoNode = importlib.import_module("py.nodes.demo_vue_widget_node").LoraManagerDemoNode
|
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
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -42,7 +44,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
DebugMetadata.NAME: DebugMetadata,
|
DebugMetadata.NAME: DebugMetadata,
|
||||||
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
|
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
|
||||||
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
|
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
|
||||||
"LoraManagerDemoNode": LoraManagerDemoNode
|
"LoraManagerDemoNode": LoraManagerDemoNode,
|
||||||
|
LoraPoolNode.NAME: LoraPoolNode
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
98
py/nodes/lora_pool.py
Normal file
98
py/nodes/lora_pool.py
Normal 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)"
|
||||||
|
}
|
||||||
11
vue-widgets/package-lock.json
generated
11
vue-widgets/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||||
"vue-tsc": "^2.1.10"
|
"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": {
|
"node_modules/vscode-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Vue-based custom widgets for ComfyUI LoRA Manager",
|
"description": "Vue-based custom widgets for ComfyUI LoRA Manager",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"primevue": "^4.2.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^9.14.0",
|
"vue-i18n": "^9.14.0"
|
||||||
"primevue": "^4.2.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@comfyorg/comfyui-frontend-types": "^1.35.4",
|
"@comfyorg/comfyui-frontend-types": "^1.35.4",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||||
"vue-tsc": "^2.1.10"
|
"vue-tsc": "^2.1.10"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
113
vue-widgets/src/components/LoraPoolWidget.vue
Normal file
113
vue-widgets/src/components/LoraPoolWidget.vue
Normal 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>
|
||||||
193
vue-widgets/src/components/lora-pool/LoraPoolPreview.vue
Normal file
193
vue-widgets/src/components/lora-pool/LoraPoolPreview.vue
Normal 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>
|
||||||
131
vue-widgets/src/components/lora-pool/LoraPoolSummaryView.vue
Normal file
131
vue-widgets/src/components/lora-pool/LoraPoolSummaryView.vue
Normal 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>
|
||||||
201
vue-widgets/src/components/lora-pool/modals/BaseModelModal.vue
Normal file
201
vue-widgets/src/components/lora-pool/modals/BaseModelModal.vue
Normal 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>
|
||||||
184
vue-widgets/src/components/lora-pool/modals/ModalWrapper.vue
Normal file
184
vue-widgets/src/components/lora-pool/modals/ModalWrapper.vue
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
170
vue-widgets/src/components/lora-pool/modals/TagsModal.vue
Normal file
170
vue-widgets/src/components/lora-pool/modals/TagsModal.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
234
vue-widgets/src/components/lora-pool/sections/FoldersSection.vue
Normal file
234
vue-widgets/src/components/lora-pool/sections/FoldersSection.vue
Normal 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>
|
||||||
132
vue-widgets/src/components/lora-pool/sections/LicenseSection.vue
Normal file
132
vue-widgets/src/components/lora-pool/sections/LicenseSection.vue
Normal 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>
|
||||||
133
vue-widgets/src/components/lora-pool/sections/TagsSection.vue
Normal file
133
vue-widgets/src/components/lora-pool/sections/TagsSection.vue
Normal 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>
|
||||||
45
vue-widgets/src/components/lora-pool/shared/EditButton.vue
Normal file
45
vue-widgets/src/components/lora-pool/shared/EditButton.vue
Normal 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>
|
||||||
109
vue-widgets/src/components/lora-pool/shared/FilterChip.vue
Normal file
109
vue-widgets/src/components/lora-pool/shared/FilterChip.vue
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
59
vue-widgets/src/composables/types.ts
Normal file
59
vue-widgets/src/composables/types.ts
Normal 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 }
|
||||||
|
}
|
||||||
116
vue-widgets/src/composables/useLoraPoolApi.ts
Normal file
116
vue-widgets/src/composables/useLoraPoolApi.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
187
vue-widgets/src/composables/useLoraPoolState.ts
Normal file
187
vue-widgets/src/composables/useLoraPoolState.ts
Normal 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>
|
||||||
31
vue-widgets/src/composables/useModalState.ts
Normal file
31
vue-widgets/src/composables/useModalState.ts
Normal 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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp, type App as VueApp } from 'vue'
|
import { createApp, type App as VueApp } from 'vue'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import DemoWidget from '@/components/DemoWidget.vue'
|
import DemoWidget from '@/components/DemoWidget.vue'
|
||||||
|
import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
|
||||||
|
|
||||||
// @ts-ignore - ComfyUI external module
|
// @ts-ignore - ComfyUI external module
|
||||||
import { app } from '../../../scripts/app.js'
|
import { app } from '../../../scripts/app.js'
|
||||||
@@ -13,7 +14,6 @@ function createVueWidget(node) {
|
|||||||
container.id = `lora-manager-demo-widget-${node.id}`
|
container.id = `lora-manager-demo-widget-${node.id}`
|
||||||
container.style.width = '100%'
|
container.style.width = '100%'
|
||||||
container.style.height = '100%'
|
container.style.height = '100%'
|
||||||
container.style.minHeight = '300px'
|
|
||||||
container.style.display = 'flex'
|
container.style.display = 'flex'
|
||||||
container.style.flexDirection = 'column'
|
container.style.flexDirection = 'column'
|
||||||
container.style.overflow = 'hidden'
|
container.style.overflow = 'hidden'
|
||||||
@@ -34,7 +34,55 @@ function createVueWidget(node) {
|
|||||||
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)
|
vueApp.mount(container)
|
||||||
vueApps.set(node.id, vueApp)
|
vueApps.set(node.id, vueApp)
|
||||||
@@ -51,23 +99,18 @@ function createVueWidget(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: 'comfyui.loramanager.demo',
|
name: 'LoraManager.VueWidgets',
|
||||||
|
|
||||||
getCustomWidgets() {
|
getCustomWidgets() {
|
||||||
return {
|
return {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
LORA_DEMO_WIDGET(node) {
|
LORA_DEMO_WIDGET(node) {
|
||||||
return createVueWidget(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)])
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
cssInjectedByJsPlugin() // Inject CSS into JS for ComfyUI compatibility
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, './src')
|
'@': resolve(__dirname, './src')
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user