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__/
|
||||
.pytest_cache/
|
||||
settings.json
|
||||
path_mappings.yaml
|
||||
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_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
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",
|
||||
"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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
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 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
|
||||
nodeCreated(node) {
|
||||
if (node.constructor?.comfyClass !== 'LoraManagerDemoNode') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 350), Math.max(oldHeight, 400)])
|
||||
LORA_POOL_CONFIG(node) {
|
||||
return createLoraPoolWidget(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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