feat(randomizer): add lora pool Vue widget

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

View File

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

View File

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

View File

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

View File

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