Compare commits

...

4 Commits

Author SHA1 Message Date
Will Miao e04c22f83f fix(widgets): allow text selection in LoraInfoWidget description tab 2026-07-17 18:33:59 +08:00
Will Miao 681cc13e90 fix(widgets): persist LoRA entry selection and active tab across save/load 2026-07-17 18:27:34 +08:00
Will Miao 090e0297d4 fix(downloader): hold session lock in retry paths to prevent session close race
Refactor _create_session() to make-before-break: snapshot old session,
assign new one first, then close old.  Previously, concurrent download
retries called _create_session() without the session lock (violating its
docstring contract) and closed the old session while other coroutines
held active references — causing aiohttp to raise "NoneType has no
attribute connect" when dereferencing the torn-down connector.

Also wrap the two _create_session() calls in the integrity-retry and
network-retry paths with self._session_lock to match the locking
discipline used by the session property and refresh_session().
2026-07-17 17:21:05 +08:00
Will Miao 6f71335be4 feat(widgets): add Description tab to LoraInfoWidget with dual-mode rendering support
- Add Notes/Description tab switching with tab state persistence in widget value
- Lazy-load model description and version description from /lm/loras/metadata
- Render CivitAI HTML descriptions inline via v-html
- Auto-fetch description when LoRA selection changes while on Description tab
- Fix Vue mode height containment via contain:layout size (lm-vue-node class)
- Fix scroll wheel isolation: widget scroll vs canvas zoom in both render modes
- Add docs/comfyui-dual-mode-widgets.md with widget rendering patterns
2026-07-17 15:04:47 +08:00
10 changed files with 1550 additions and 189 deletions
+1
View File
@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
- Event handlers via `addEventListener` or widget callbacks
- Shared utilities: `web/comfyui/utils.js`
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
### Vue Composables Pattern
+65
View File
@@ -0,0 +1,65 @@
# ComfyUI Dual-Mode Widget Rendering
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
## Mode Detection
```js
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
```
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
## Canvas Mode Layout
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
- `widget.computeLayoutSize()``{ minHeight, minWidth, maxHeight? }`
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
## Vue Mode Layout
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
### Height Containment
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
```css
.widget-root.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 200px);
contain: layout size;
}
```
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
## Scroll Wheel Isolation
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
## DOM Structure
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
- `container.id` / `container.style.*` → outer element
- Vue scoped `<style>``[data-v-hash]` applies only to Vue root
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
## Serialization
For stateful widgets that need workflow persistence:
- `serialize: true` in `addDOMWidget` options
- `serializeValue()` → state snapshot (called on workflow save)
- `onSetValue(v)` → restore state (called on workflow load)
- Always handle missing keys in restored value for backward compatibility with old workflows
+16 -7
View File
@@ -270,13 +270,13 @@ class Downloader:
Note: This is private and caller MUST hold self._session_lock.
"""
# Close existing session if any
if self._session is not None:
try:
await self._session.close()
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
finally:
# Snapshot and clear old session reference before creating the new
# one. This ensures self._session is always valid (or None, which
# triggers a fresh creation) and avoids a race where concurrent
# requests hold a reference to a session whose connector has been
# torn down by a premature close() call — the root cause of the
# intermittent "NoneType has no attribute connect" crash.
old_session = self._session
self._session = None
# Check for app-level proxy settings
@@ -372,6 +372,13 @@ class Downloader:
self._proxy_url = proxy_url
self._session_created_at = datetime.now()
# Close the previous session now that the replacement is live.
if old_session is not None:
try:
await old_session.close()
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
logger.debug(
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
bool(proxy_url),
@@ -753,6 +760,7 @@ class Downloader:
else:
resume_offset = 0
total_size = 0
async with self._session_lock:
await self._create_session()
continue
@@ -843,6 +851,7 @@ class Downloader:
logger.info(f"Will resume from byte {resume_offset}")
# Refresh session to get new connection
async with self._session_lock:
await self._create_session()
continue
else:
+419 -26
View File
@@ -1,6 +1,37 @@
<template>
<div class="lora-info-widget">
<div class="lora-info-widget" :class="{ 'lm-vue-node': isVueMode }" @wheel="onWheel">
<template v-if="loraName">
<!-- Tab bar -->
<div class="lora-info-tabs">
<label
class="lora-info-tab"
:class="{ active: activeTab === 'notes' }"
>
<input
type="radio"
v-model="activeTab"
value="notes"
class="lora-info-tab-input"
/>
<span class="lora-info-tab-label">Notes</span>
</label>
<label
class="lora-info-tab"
:class="{ active: activeTab === 'description' }"
>
<input
type="radio"
v-model="activeTab"
value="description"
class="lora-info-tab-input"
@change="onDescriptionTabActivated"
/>
<span class="lora-info-tab-label">Description</span>
</label>
</div>
<!-- Notes tab content -->
<div v-show="activeTab === 'notes'" class="tab-content notes-tab">
<div class="info-field">
<label class="info-label">Filename</label>
<div class="lora-filename">{{ loraName }}</div>
@@ -9,7 +40,7 @@
<label class="info-label">Notes</label>
<textarea
v-model="notes"
class="lora-notes"
class="lora-notes lm-wheel-scrollable"
placeholder="Add notes about this LoRA..."
:disabled="saving"
></textarea>
@@ -21,20 +52,66 @@
>
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
<!-- Description tab content -->
<div v-show="activeTab === 'description'" class="tab-content description-tab lm-wheel-scrollable">
<!-- Loading state -->
<div v-if="descriptionLoading" class="description-state">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading description...</span>
</div>
<!-- Error state -->
<div v-else-if="descriptionError" class="description-state error">
<span>Failed to load description</span>
</div>
<!-- Empty state (loaded but no content) -->
<div v-else-if="!hasDescription" class="description-state placeholder">
<span>No description available</span>
</div>
<!-- Description content -->
<div v-else class="description-content">
<div v-if="versionDescription" class="description-section">
<label class="info-label">About this version</label>
<div class="description-text" v-html="versionDescription"></div>
</div>
<div v-if="modelDescription" class="description-section">
<label class="info-label">Model Description</label>
<div class="description-text" v-html="modelDescription"></div>
</div>
</div>
</div>
</template>
<div v-else class="placeholder">No LoRA selected</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, computed, watch } from 'vue'
interface LoraInfoWidget {
serializeValue?: () => Promise<unknown>
value?: unknown
onSetValue?: (v: unknown) => void
callback?: unknown
_setLoraInfo?: (data: { name: string; notes: string; filePath: string }) => void
options?: {
getValue?: () => LoraInfoWidgetValue
setValue?: (v: unknown) => void
}
node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> }
id?: string
_setLoraInfo?: (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => void
__pendingLoraInfo?: { name: string; notes: string; filePath: string; activeTab?: string } | null
}
interface LoraInfoWidgetValue {
name?: string
notes?: string
filePath?: string
activeTab?: string
}
const props = defineProps<{
@@ -42,6 +119,7 @@ const props = defineProps<{
node: { id: number }
api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> }
app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } }
isVueMode?: boolean
}>()
const loraName = ref<string>('')
@@ -49,6 +127,69 @@ const notes = ref<string>('')
const originalNotes = ref<string>('')
const filePath = ref<string>('')
const saving = ref<boolean>(false)
const activeTab = ref<string>('notes')
// Description tab state
const versionDescription = ref<string>('')
const modelDescription = ref<string>('')
const descriptionLoading = ref<boolean>(false)
const descriptionError = ref<boolean>(false)
const descriptionLoaded = ref<boolean>(false)
const hasDescription = computed(() =>
!!(versionDescription.value || modelDescription.value)
)
// Reset and auto-fetch description state when the LoRA selection changes
watch(filePath, (newPath) => {
descriptionLoaded.value = false
descriptionError.value = false
versionDescription.value = ''
modelDescription.value = ''
if (newPath && activeTab.value === 'description') {
fetchDescription()
}
})
function onDescriptionTabActivated() {
if (!descriptionLoaded.value && filePath.value) {
fetchDescription()
}
}
async function fetchDescription() {
if (descriptionLoading.value || !filePath.value) return
descriptionLoading.value = true
descriptionError.value = false
try {
const response = await props.api.fetchApi(
`/lm/loras/metadata?file_path=${encodeURIComponent(filePath.value)}`,
{ method: 'GET' }
)
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.statusText}`)
}
const data = await response.json()
if (data.success && data.metadata) {
versionDescription.value = data.metadata.description || ''
modelDescription.value = data.metadata.model?.description || ''
descriptionLoaded.value = true
} else {
// Successful response but no metadata treat as empty, not error
descriptionLoaded.value = true
}
} catch (e) {
console.error('[LoraInfoWidget] Failed to fetch description:', e)
descriptionError.value = true
// Don't set descriptionLoaded allow retry on next tab switch
} finally {
descriptionLoading.value = false
}
}
async function saveNotes() {
if (notes.value === originalNotes.value || saving.value) return
@@ -91,47 +232,120 @@ async function saveNotes() {
}
}
function onWheel(event: WheelEvent) {
const target = event.target as HTMLElement | null
if (!target) return
const comfyApp = (window as unknown as { app?: { canvas?: { processMouseWheel?: (e: WheelEvent) => void } } }).app
if (!comfyApp?.canvas?.processMouseWheel) return
// Always pass pinch-to-zoom to canvas
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
return
}
// Horizontal scroll: pass to canvas
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
return
}
// Check if the target is inside a scrollable area (notes textarea or description tab)
const scrollableEl = target.closest('.lora-notes, .description-tab') as HTMLElement | null
if (scrollableEl) {
const canScrollY = scrollableEl.scrollHeight > scrollableEl.clientHeight
if (canScrollY) {
// Let native scroll handle it, but stop propagation to prevent canvas zoom
event.stopPropagation()
return
}
}
// Forward to canvas for zoom
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
}
onMounted(() => {
// Display-only widget - return null on serialization to avoid saving to workflow
props.widget.serializeValue = async () => null
// Build current state snapshot for serialization
const buildValue = (): LoraInfoWidgetValue => ({
name: loraName.value,
notes: notes.value,
filePath: filePath.value,
activeTab: activeTab.value,
})
// Set value from external source (workflow load, paste, etc.)
const applyValue = (v: unknown) => {
if (v && typeof v === 'object') {
const data = v as LoraInfoWidgetValue
// Set activeTab before filePath so the filePath watcher sees the correct tab
// and triggers fetchDescription() when restoring description tab
if (data.activeTab !== undefined) activeTab.value = data.activeTab
if (data.name !== undefined) loraName.value = data.name
if (data.notes !== undefined) {
notes.value = data.notes
originalNotes.value = data.notes
}
if (data.filePath !== undefined) filePath.value = data.filePath
}
}
// ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue.
// These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work.
props.widget.options.getValue = buildValue
props.widget.options.setValue = applyValue
// Also set serializeValue for prompt/API serialization path (executionUtil.ts)
props.widget.serializeValue = async () => buildValue()
// Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = (v: unknown) => {
if (v && typeof v === 'object') {
const data = v as { name?: string; notes?: string; filePath?: string }
if (data.name !== undefined) loraName.value = data.name
if (data.notes !== undefined) {
notes.value = data.notes
originalNotes.value = data.notes
}
if (data.filePath !== undefined) filePath.value = data.filePath
}
}
props.widget.onSetValue = applyValue
// Restore from saved value if exists (for workflow loading)
if (props.widget.value && typeof props.widget.value === 'object') {
const data = props.widget.value as { name?: string; notes?: string; filePath?: string }
if (data.name !== undefined) loraName.value = data.name
if (data.notes !== undefined) {
notes.value = data.notes
originalNotes.value = data.notes
// Restore from saved value. Because configure() may call widget.value = data
// before onMounted fires (and before options.setValue is assigned), we check
// widgets_values directly in case the value was already pushed.
const widgetIndex = props.widget.node?.widgets?.findIndex(
(w: { id?: string }) => w.id === props.widget.id
)
let restored = false
if (widgetIndex !== undefined && widgetIndex >= 0) {
const savedValue = props.widget.node?.widgets_values?.[widgetIndex]
if (savedValue && typeof savedValue === 'object') {
applyValue(savedValue)
restored = true
}
if (data.filePath !== undefined) filePath.value = data.filePath
}
// Fallback: if configure() ran after onMounted, widget.value (via options.getValue)
// already has the saved data. Only use this path if the widgets_values lookup didn't restore.
if (!restored && props.widget.value && typeof props.widget.value === 'object') {
applyValue(props.widget.value)
}
// Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js).
// Accepts null to clear the display (when selection is deselected).
props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string } | null) => {
props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => {
if (data) {
loraName.value = data.name
notes.value = data.notes
originalNotes.value = data.notes
filePath.value = data.filePath
// Preserve existing activeTab unless explicitly provided
if (data.activeTab !== undefined) {
activeTab.value = data.activeTab
}
} else {
loraName.value = ''
notes.value = ''
originalNotes.value = ''
filePath.value = ''
// Do NOT reset activeTab on deselection user's tab preference persists
}
}
@@ -155,6 +369,86 @@ onMounted(() => {
overflow: hidden;
}
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
contain:layout size tells the browser the element's intrinsic size is
determined solely by CSS not by descendant content. This breaks the
feedback loop where content grows ResizeObserver resizes content
reflows repeat. Same technique used by tags_widget.js + lm_styles.css. */
.lora-info-widget.lm-vue-node {
contain: layout size;
}
/* ── Tab bar ── */
.lora-info-tabs {
display: flex;
gap: 0;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color, #444);
flex-shrink: 0;
}
.lora-info-tab {
flex: 1;
text-align: center;
cursor: pointer;
padding: 6px 0;
position: relative;
}
.lora-info-tab-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.lora-info-tab-label {
font-size: 12px;
font-weight: 500;
color: var(--fg-color, #fff);
opacity: 0.5;
transition: opacity 0.15s;
}
.lora-info-tab:hover .lora-info-tab-label {
opacity: 0.75;
}
.lora-info-tab.active .lora-info-tab-label {
opacity: 1;
}
.lora-info-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 25%;
right: 25%;
height: 2px;
background: rgba(66, 153, 225, 0.8);
border-radius: 1px;
}
/* ── Tab content ── */
.tab-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.notes-tab {
display: flex;
flex-direction: column;
}
.description-tab {
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
}
/* ── Info fields (shared) ── */
.info-field {
display: flex;
flex-direction: column;
@@ -176,6 +470,10 @@ onMounted(() => {
color: var(--fg-color, #fff);
word-break: break-all;
margin-bottom: 8px;
/* Override node-level grab cursor and user-select:none from .lg-node.cursor-grab */
cursor: auto;
user-select: text;
-webkit-user-select: text;
}
.notes-field {
@@ -235,6 +533,91 @@ onMounted(() => {
border-color: rgba(226, 232, 240, 0.1);
}
/* ── Description states ── */
.description-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 16px;
color: var(--fg-color, #fff);
opacity: 0.5;
font-size: 12px;
min-height: 0;
flex-shrink: 0;
}
.description-state.error {
opacity: 0.7;
color: #f87171;
}
/* ── Description content ── */
.description-content {
min-height: 0;
}
.description-section {
margin-bottom: 14px;
}
.description-section:last-child {
margin-bottom: 0;
}
.description-text {
padding: 8px 0;
font-size: 12px;
line-height: 1.5;
color: var(--fg-color, #fff);
opacity: 0.85;
word-break: break-word;
/* Override node-level grab cursor and user-select:none from .lg-node.cursor-grab */
cursor: auto;
user-select: text;
-webkit-user-select: text;
}
.description-text :deep(p) {
margin: 0 0 8px 0;
}
.description-text :deep(p:last-child) {
margin-bottom: 0;
}
.description-text :deep(a) {
color: rgba(66, 153, 225, 0.9);
}
.description-text :deep(ul),
.description-text :deep(ol) {
padding-left: 20px;
margin: 4px 0;
}
.description-text :deep(h1),
.description-text :deep(h2),
.description-text :deep(h3) {
font-size: 13px;
margin: 10px 0 4px 0;
font-weight: 600;
opacity: 0.95;
}
.description-text :deep(code) {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.description-text :deep(img) {
max-width: 100%;
border-radius: 4px;
}
/* ── Placeholder (shared) ── */
.placeholder {
font-style: italic;
color: rgba(226, 232, 240, 0.5);
@@ -242,4 +625,14 @@ onMounted(() => {
padding: 16px 0;
font-size: 12px;
}
/* ── Spinner (Font Awesome) ── */
.fa-spinner {
animation: fa-spin 1s linear infinite;
}
@keyframes fa-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
+4 -3
View File
@@ -656,7 +656,7 @@ function createLoraInfoWidget(node: any) {
forwardMiddleMouseToCanvas(container)
let internalValue: { name?: string; notes?: string; filePath?: string } | undefined
let internalValue: { name?: string; notes?: string; filePath?: string; activeTab?: string } | undefined
const widget = node.addDOMWidget(
'lora_info_display',
@@ -666,13 +666,13 @@ function createLoraInfoWidget(node: any) {
getValue() {
return internalValue
},
setValue(v: { name?: string; notes?: string; filePath?: string }) {
setValue(v: { name?: string; notes?: string; filePath?: string; activeTab?: string }) {
internalValue = v
if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v)
}
},
serialize: false, // Display-only widget
serialize: true,
getMinHeight() {
return LORA_INFO_WIDGET_MIN_HEIGHT
}
@@ -684,6 +684,7 @@ function createLoraInfoWidget(node: any) {
node,
api,
app,
isVueMode: typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode,
})
vueApp.use(PrimeVue, {
@@ -0,0 +1,417 @@
/**
* Tests for LoraInfoWidget tab switching, lazy description loading,
* state serialization roundtrip, and activeTab persistence.
*/
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
import { setupFetchMock, resetFetchMock } from '../setup'
// ── Helpers ──
function createMockFetchApi(overrides: {
response?: unknown
ok?: boolean
error?: string
} = {}) {
const { response = { success: true, metadata: {} }, ok = true } = overrides
return vi.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(response),
})
}
function createMockToast() {
return { add: vi.fn() }
}
function createMockWidget(value?: unknown) {
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
const widget = {
serializeValue: (async () => null) as () => Promise<unknown>,
value: (value ?? undefined) as unknown,
onSetValue: undefined as unknown as ((v: unknown) => void),
_setLoraInfo: undefined as unknown as (data: Record<string, unknown> | null) => void,
__pendingLoraInfo: undefined as unknown as PendingInfo | undefined,
}
return widget
}
interface MountOptions {
initialValue?: Record<string, unknown>
}
type TestWidget = ReturnType<typeof createMockWidget>
function mountWidget(options: MountOptions = {}) {
const fetchApi = createMockFetchApi()
const widget = createMockWidget(options.initialValue)
const node = { id: 1 }
const app = { extensionManager: { toast: createMockToast() } }
const wrapper = shallowMount(LoraInfoWidget, {
props: { widget, node, api: { fetchApi }, app },
})
return { wrapper, widget: widget as TestWidget, fetchApi, app }
}
// ── Tests ──
describe('LoraInfoWidget', () => {
beforeEach(() => {
setupFetchMock()
})
afterEach(() => {
resetFetchMock()
})
describe('initial state', () => {
it('shows placeholder when no LoRA is selected', () => {
const { wrapper } = mountWidget()
expect(wrapper.text()).toContain('No LoRA selected')
})
it('shows Notes tab by default when LoRA is set', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
expect(wrapper.text()).toContain('test.safetensors')
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
expect(wrapper.find('.description-tab').isVisible()).toBe(false)
})
})
describe('tab switching', () => {
it('switches to Description tab and back to Notes', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const tabs = wrapper.findAll('.lora-info-tab')
// Click Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
expect(tabs[1].classes()).toContain('active')
expect(wrapper.text()).toContain('No description available')
// Switch back to Notes
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
await notesTab.setValue('notes')
await nextTick()
expect(tabs[0].classes()).toContain('active')
expect(wrapper.text()).toContain('test.safetensors')
})
})
describe('description lazy loading', () => {
it('fetches metadata when Description tab is activated', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick() // flush async fetch
expect(fetchApi).toHaveBeenCalledWith(
expect.stringContaining('/lm/loras/metadata'),
expect.objectContaining({ method: 'GET' })
)
expect(wrapper.html()).toContain('Version desc')
expect(wrapper.html()).toContain('Model desc')
})
it('shows loading state while fetching', async () => {
// Use a never-resolving promise to simulate loading
const fetchApi = vi.fn().mockReturnValue(new Promise(() => {}))
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
expect(wrapper.text()).toContain('Loading description')
})
it('shows error state when fetch fails', async () => {
const fetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(wrapper.text()).toContain('Failed to load description')
})
it('shows empty state when metadata has no descriptions', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '',
model: {},
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(wrapper.text()).toContain('No description available')
})
it('caches description and does not re-fetch on second activation', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// First activation
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(1)
// Switch away and back
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
await notesTab.setValue('notes')
await nextTick()
await descriptionTab.setValue('description')
await nextTick()
// Should NOT have called fetch again
expect(fetchApi).toHaveBeenCalledTimes(1)
})
it('re-fetches when LoRA selection changes', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'first.safetensors', notes: '', filePath: '/path/first.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(1)
// Select a different LoRA — resets description state
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
await nextTick()
// Should show loading again (not cached)
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(2)
})
})
describe('serialization roundtrip', () => {
it('serializeValue includes activeTab', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: 'my notes', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
const serialized = await widget.serializeValue!()
expect(serialized).toMatchObject({
name: 'test.safetensors',
notes: 'my notes',
filePath: '/path/test.safetensors',
activeTab: 'description',
})
})
it('onSetValue restores activeTab from workflow value', async () => {
const { wrapper } = mountWidget({
initialValue: {
name: 'saved.safetensors',
notes: 'saved notes',
filePath: '/path/saved.safetensors',
activeTab: 'description',
},
})
await nextTick()
// Description tab should be visible (activeTab restored to 'description')
expect(wrapper.find('.description-tab').isVisible()).toBe(true)
expect(wrapper.text()).toContain('saved.safetensors')
})
it('defaults to notes tab when activeTab is missing in saved value', async () => {
const { wrapper } = mountWidget({
initialValue: {
name: 'legacy.safetensors',
notes: 'legacy notes',
filePath: '/path/legacy.safetensors',
// No activeTab — legacy workflow
},
})
await nextTick()
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
})
})
describe('_setLoraInfo race condition guard', () => {
it('consumes __pendingLoraInfo pushed before mount', async () => {
const widget = createMockWidget()
widget.__pendingLoraInfo = {
name: 'pending.safetensors',
notes: 'pending notes',
filePath: '/path/pending.safetensors',
}
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi: createMockFetchApi() },
app: { extensionManager: { toast: createMockToast() } },
},
})
await nextTick()
expect(widget.__pendingLoraInfo).toBeUndefined()
expect(wrapper.text()).toContain('pending.safetensors')
})
it('preserves activeTab when _setLoraInfo called with null (deselection)', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
// Deselect — template shows placeholder (no tab bar rendered)
widget._setLoraInfo!(null)
await nextTick()
// Placeholder shown
expect(wrapper.text()).toContain('No LoRA selected')
// Re-select — activeTab should still be 'description'
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
await nextTick()
const tabs = wrapper.findAll('.lora-info-tab')
expect(tabs[1].classes()).toContain('active')
})
})
})
+14 -1
View File
@@ -711,7 +711,11 @@ export function addLorasWidget(node, name, opts, callback) {
// Create widget with new DOM Widget API
const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() {
return widgetValue;
return widgetValue.map(lora => {
const entry = { ...lora };
entry.selected = lora.name === selectedLora;
return entry;
});
},
setValue: function(v) {
// Remove duplicates by keeping the last occurrence of each lora name
@@ -738,6 +742,15 @@ export function addLorasWidget(node, name, opts, callback) {
});
widgetValue = updatedValue;
// Restore selection state when loading a saved workflow
if (!selectedLora) {
const selectedEntry = updatedValue.find(lora => lora.selected);
if (selectedEntry) {
selectedLora = selectedEntry.name;
}
}
renderLoras(widgetValue, widget);
},
hideOnZoom: true,
+2
View File
@@ -438,6 +438,7 @@ export function mergeLoras(lorasText, lorasArr) {
active: lora.active !== undefined ? lora.active : true,
expanded: lora.expanded !== undefined ? lora.expanded : false,
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
selected: !!lora.selected,
});
usedNames.add(lora.name);
}
@@ -451,6 +452,7 @@ export function mergeLoras(lorasText, lorasArr) {
strength: parsedLoras[name].strength,
active: true,
clipStrength: parsedLoras[name].clipStrength,
selected: false,
});
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long