fix: Show all tags in LoRA Pool without limit (#819)

- Backend: Support limit=0 to return all tags in top-tags API
- Frontend: Remove tags limit setting and fetch all tags by default
- UI: Implement virtual scrolling in TagsModal for performance
  - Initial display 200 tags, load more on scroll
  - Show all results when searching
- Remove lora_pool_tags_limit setting to simplify UX

Fixes #819
This commit is contained in:
Will Miao
2026-02-19 09:59:08 +08:00
parent b9516c6b62
commit e8b37365a6
6 changed files with 144 additions and 36 deletions

View File

@@ -648,7 +648,7 @@ class ModelQueryHandler:
async def get_top_tags(self, request: web.Request) -> web.Response: async def get_top_tags(self, request: web.Request) -> web.Response:
try: try:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
if limit < 1 or limit > 100: if limit < 0:
limit = 20 limit = 20
top_tags = await self._service.get_top_tags(limit) top_tags = await self._service.get_top_tags(limit)
return web.json_response({"success": True, "tags": top_tags}) return web.json_response({"success": True, "tags": top_tags})

View File

@@ -1448,7 +1448,7 @@ class ModelScanner:
return None return None
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]: async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get top tags sorted by count""" """Get top tags sorted by count. If limit is 0, return all tags."""
await self.get_cached_data() await self.get_cached_data()
sorted_tags = sorted( sorted_tags = sorted(
@@ -1457,6 +1457,8 @@ class ModelScanner:
reverse=True reverse=True
) )
if limit == 0:
return sorted_tags
return sorted_tags[:limit] return sorted_tags[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:

View File

@@ -31,9 +31,9 @@
</div> </div>
</template> </template>
<div class="tags-container"> <div ref="tagsContainerRef" class="tags-container" @scroll="handleScroll">
<button <button
v-for="tag in filteredTags" v-for="tag in visibleTags"
:key="tag.tag" :key="tag.tag"
type="button" type="button"
class="tag-chip" class="tag-chip"
@@ -42,9 +42,12 @@
> >
{{ tag.tag }} {{ tag.tag }}
</button> </button>
<div v-if="filteredTags.length === 0" class="no-results"> <div v-if="visibleTags.length === 0" class="no-results">
No tags found No tags found
</div> </div>
<div v-if="hasMoreTags" class="load-more-hint">
Scroll to load more...
</div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
@@ -78,6 +81,11 @@ const subtitle = computed(() =>
const searchQuery = ref('') const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const tagsContainerRef = ref<HTMLElement | null>(null)
const displayedCount = ref(200)
const BATCH_SIZE = 200
const SCROLL_THRESHOLD = 100
const filteredTags = computed(() => { const filteredTags = computed(() => {
if (!searchQuery.value) { if (!searchQuery.value) {
@@ -87,6 +95,20 @@ const filteredTags = computed(() => {
return props.tags.filter(t => t.tag.toLowerCase().includes(query)) return props.tags.filter(t => t.tag.toLowerCase().includes(query))
}) })
const visibleTags = computed(() => {
// When searching, show all filtered results
if (searchQuery.value) {
return filteredTags.value
}
// Otherwise, use virtual scrolling
return filteredTags.value.slice(0, displayedCount.value)
})
const hasMoreTags = computed(() => {
if (searchQuery.value) return false
return displayedCount.value < filteredTags.value.length
})
const isSelected = (tag: string) => { const isSelected = (tag: string) => {
return props.selected.includes(tag) return props.selected.includes(tag)
} }
@@ -100,16 +122,40 @@ const toggleTag = (tag: string) => {
const clearSearch = () => { const clearSearch = () => {
searchQuery.value = '' searchQuery.value = ''
displayedCount.value = BATCH_SIZE
searchInputRef.value?.focus() searchInputRef.value?.focus()
} }
const handleScroll = () => {
if (searchQuery.value) return
const container = tagsContainerRef.value
if (!container) return
const { scrollTop, scrollHeight, clientHeight } = container
const scrollBottom = scrollHeight - scrollTop - clientHeight
// Load more tags when user scrolls near bottom
if (scrollBottom < SCROLL_THRESHOLD && hasMoreTags.value) {
displayedCount.value = Math.min(
displayedCount.value + BATCH_SIZE,
filteredTags.value.length
)
}
}
watch(() => props.visible, (isVisible) => { watch(() => props.visible, (isVisible) => {
if (isVisible) { if (isVisible) {
displayedCount.value = BATCH_SIZE
nextTick(() => { nextTick(() => {
searchInputRef.value?.focus() searchInputRef.value?.focus()
}) })
} }
}) })
watch(() => props.tags, () => {
displayedCount.value = BATCH_SIZE
})
</script> </script>
<style scoped> <style scoped>
@@ -245,4 +291,14 @@ watch(() => props.visible, (isVisible) => {
opacity: 0.5; opacity: 0.5;
font-size: 13px; font-size: 13px;
} }
.load-more-hint {
width: 100%;
padding: 12px;
text-align: center;
color: var(--fg-color, #fff);
opacity: 0.4;
font-size: 12px;
font-style: italic;
}
</style> </style>

View File

@@ -15,7 +15,7 @@ export function useLoraPoolApi() {
} }
} }
const fetchTags = async (limit = 100): Promise<TagOption[]> => { const fetchTags = async (limit = 0): Promise<TagOption[]> => {
try { try {
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`) const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`)
const data = await response.json() const data = await response.json()

View File

@@ -708,10 +708,10 @@ to { transform: rotate(360deg);
font-size: 13px; font-size: 13px;
} }
.search-container[data-v-110d6f7d] { .search-container[data-v-48c2535d] {
position: relative; position: relative;
} }
.search-icon[data-v-110d6f7d] { .search-icon[data-v-48c2535d] {
position: absolute; position: absolute;
left: 10px; left: 10px;
top: 50%; top: 50%;
@@ -721,7 +721,7 @@ to { transform: rotate(360deg);
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
opacity: 0.5; opacity: 0.5;
} }
.search-input[data-v-110d6f7d] { .search-input[data-v-48c2535d] {
width: 100%; width: 100%;
padding: 8px 12px 8px 32px; padding: 8px 12px 8px 32px;
background: var(--comfy-input-bg, #333); background: var(--comfy-input-bg, #333);
@@ -731,14 +731,14 @@ to { transform: rotate(360deg);
font-size: 13px; font-size: 13px;
outline: none; outline: none;
} }
.search-input[data-v-110d6f7d]:focus { .search-input[data-v-48c2535d]:focus {
border-color: var(--fg-color, #fff); border-color: var(--fg-color, #fff);
} }
.search-input[data-v-110d6f7d]::placeholder { .search-input[data-v-48c2535d]::placeholder {
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
opacity: 0.4; opacity: 0.4;
} }
.clear-button[data-v-110d6f7d] { .clear-button[data-v-48c2535d] {
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 50%; top: 50%;
@@ -755,20 +755,20 @@ to { transform: rotate(360deg);
opacity: 0.5; opacity: 0.5;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.clear-button[data-v-110d6f7d]:hover { .clear-button[data-v-48c2535d]:hover {
opacity: 0.8; opacity: 0.8;
} }
.clear-button svg[data-v-110d6f7d] { .clear-button svg[data-v-48c2535d] {
width: 12px; width: 12px;
height: 12px; height: 12px;
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
} }
.tags-container[data-v-110d6f7d] { .tags-container[data-v-48c2535d] {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.tag-chip[data-v-110d6f7d] { .tag-chip[data-v-48c2535d] {
padding: 6px 12px; padding: 6px 12px;
background: var(--comfy-input-bg, #333); background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #555); border: 1px solid var(--border-color, #555);
@@ -780,48 +780,48 @@ to { transform: rotate(360deg);
} }
/* Default hover (gray for neutral) */ /* Default hover (gray for neutral) */
.tag-chip[data-v-110d6f7d]:hover:not(.tag-chip--selected) { .tag-chip[data-v-48c2535d]:hover:not(.tag-chip--selected) {
border-color: rgba(226, 232, 240, 0.5); border-color: rgba(226, 232, 240, 0.5);
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
/* Include variant hover - blue tint */ /* Include variant hover - blue tint */
.tags-modal--include .tag-chip[data-v-110d6f7d]:hover:not(.tag-chip--selected) { .tags-modal--include .tag-chip[data-v-48c2535d]:hover:not(.tag-chip--selected) {
border-color: rgba(66, 153, 225, 0.4); border-color: rgba(66, 153, 225, 0.4);
background: rgba(66, 153, 225, 0.08); background: rgba(66, 153, 225, 0.08);
} }
/* Exclude variant hover - red tint */ /* Exclude variant hover - red tint */
.tags-modal--exclude .tag-chip[data-v-110d6f7d]:hover:not(.tag-chip--selected) { .tags-modal--exclude .tag-chip[data-v-48c2535d]:hover:not(.tag-chip--selected) {
border-color: rgba(239, 68, 68, 0.4); border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.08); background: rgba(239, 68, 68, 0.08);
} }
/* Selected chips hover - slightly deepen the color */ /* Selected chips hover - slightly deepen the color */
.tags-modal--include .tag-chip--selected[data-v-110d6f7d]:hover { .tags-modal--include .tag-chip--selected[data-v-48c2535d]:hover {
background: rgba(66, 153, 225, 0.25); background: rgba(66, 153, 225, 0.25);
border-color: rgba(66, 153, 225, 0.7); border-color: rgba(66, 153, 225, 0.7);
} }
.tags-modal--exclude .tag-chip--selected[data-v-110d6f7d]:hover { .tags-modal--exclude .tag-chip--selected[data-v-48c2535d]:hover {
background: rgba(239, 68, 68, 0.25); background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.7); border-color: rgba(239, 68, 68, 0.7);
} }
/* Include variant - blue when selected */ /* Include variant - blue when selected */
.tags-modal--include .tag-chip--selected[data-v-110d6f7d], .tags-modal--include .tag-chip--selected[data-v-48c2535d],
.tag-chip--selected[data-v-110d6f7d] { .tag-chip--selected[data-v-48c2535d] {
background: rgba(66, 153, 225, 0.2); background: rgba(66, 153, 225, 0.2);
border-color: rgba(66, 153, 225, 0.6); border-color: rgba(66, 153, 225, 0.6);
color: #4299e1; color: #4299e1;
} }
/* Exclude variant - red when selected */ /* Exclude variant - red when selected */
.tags-modal--exclude .tag-chip--selected[data-v-110d6f7d] { .tags-modal--exclude .tag-chip--selected[data-v-48c2535d] {
background: rgba(239, 68, 68, 0.2); background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6); border-color: rgba(239, 68, 68, 0.6);
color: #ef4444; color: #ef4444;
} }
.no-results[data-v-110d6f7d] { .no-results[data-v-48c2535d] {
width: 100%; width: 100%;
padding: 20px; padding: 20px;
text-align: center; text-align: center;
@@ -829,6 +829,15 @@ to { transform: rotate(360deg);
opacity: 0.5; opacity: 0.5;
font-size: 13px; font-size: 13px;
} }
.load-more-hint[data-v-48c2535d] {
width: 100%;
padding: 12px;
text-align: center;
color: var(--fg-color, #fff);
opacity: 0.4;
font-size: 12px;
font-style: italic;
}
.tree-node__item[data-v-90187dd4] { .tree-node__item[data-v-90187dd4] {
display: flex; display: flex;
@@ -11219,12 +11228,17 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
}); });
const BaseModelModal = /* @__PURE__ */ _export_sfc(_sfc_main$e, [["__scopeId", "data-v-e02ca44a"]]); const BaseModelModal = /* @__PURE__ */ _export_sfc(_sfc_main$e, [["__scopeId", "data-v-e02ca44a"]]);
const _hoisted_1$d = { class: "search-container" }; const _hoisted_1$d = { class: "search-container" };
const _hoisted_2$9 = { class: "tags-container" }; const _hoisted_2$9 = ["onClick"];
const _hoisted_3$8 = ["onClick"]; const _hoisted_3$8 = {
const _hoisted_4$6 = {
key: 0, key: 0,
class: "no-results" class: "no-results"
}; };
const _hoisted_4$6 = {
key: 1,
class: "load-more-hint"
};
const BATCH_SIZE = 200;
const SCROLL_THRESHOLD = 100;
const _sfc_main$d = /* @__PURE__ */ defineComponent({ const _sfc_main$d = /* @__PURE__ */ defineComponent({
__name: "TagsModal", __name: "TagsModal",
props: { props: {
@@ -11245,6 +11259,8 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
); );
const searchQuery = ref(""); const searchQuery = ref("");
const searchInputRef = ref(null); const searchInputRef = ref(null);
const tagsContainerRef = ref(null);
const displayedCount = ref(200);
const filteredTags = computed(() => { const filteredTags = computed(() => {
if (!searchQuery.value) { if (!searchQuery.value) {
return props.tags; return props.tags;
@@ -11252,6 +11268,16 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
return props.tags.filter((t) => t.tag.toLowerCase().includes(query)); return props.tags.filter((t) => t.tag.toLowerCase().includes(query));
}); });
const visibleTags = computed(() => {
if (searchQuery.value) {
return filteredTags.value;
}
return filteredTags.value.slice(0, displayedCount.value);
});
const hasMoreTags = computed(() => {
if (searchQuery.value) return false;
return displayedCount.value < filteredTags.value.length;
});
const isSelected = (tag) => { const isSelected = (tag) => {
return props.selected.includes(tag); return props.selected.includes(tag);
}; };
@@ -11262,16 +11288,34 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
const clearSearch = () => { const clearSearch = () => {
var _a2; var _a2;
searchQuery.value = ""; searchQuery.value = "";
displayedCount.value = BATCH_SIZE;
(_a2 = searchInputRef.value) == null ? void 0 : _a2.focus(); (_a2 = searchInputRef.value) == null ? void 0 : _a2.focus();
}; };
const handleScroll = () => {
if (searchQuery.value) return;
const container = tagsContainerRef.value;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const scrollBottom = scrollHeight - scrollTop - clientHeight;
if (scrollBottom < SCROLL_THRESHOLD && hasMoreTags.value) {
displayedCount.value = Math.min(
displayedCount.value + BATCH_SIZE,
filteredTags.value.length
);
}
};
watch(() => props.visible, (isVisible) => { watch(() => props.visible, (isVisible) => {
if (isVisible) { if (isVisible) {
displayedCount.value = BATCH_SIZE;
nextTick(() => { nextTick(() => {
var _a2; var _a2;
(_a2 = searchInputRef.value) == null ? void 0 : _a2.focus(); (_a2 = searchInputRef.value) == null ? void 0 : _a2.focus();
}); });
} }
}); });
watch(() => props.tags, () => {
displayedCount.value = BATCH_SIZE;
});
return (_ctx, _cache) => { return (_ctx, _cache) => {
return openBlock(), createBlock(ModalWrapper, { return openBlock(), createBlock(ModalWrapper, {
visible: __props.visible, visible: __props.visible,
@@ -11315,24 +11359,30 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
]) ])
]), ]),
default: withCtx(() => [ default: withCtx(() => [
createBaseVNode("div", _hoisted_2$9, [ createBaseVNode("div", {
(openBlock(true), createElementBlock(Fragment, null, renderList(filteredTags.value, (tag) => { ref_key: "tagsContainerRef",
ref: tagsContainerRef,
class: "tags-container",
onScroll: handleScroll
}, [
(openBlock(true), createElementBlock(Fragment, null, renderList(visibleTags.value, (tag) => {
return openBlock(), createElementBlock("button", { return openBlock(), createElementBlock("button", {
key: tag.tag, key: tag.tag,
type: "button", type: "button",
class: normalizeClass(["tag-chip", { "tag-chip--selected": isSelected(tag.tag) }]), class: normalizeClass(["tag-chip", { "tag-chip--selected": isSelected(tag.tag) }]),
onClick: ($event) => toggleTag(tag.tag) onClick: ($event) => toggleTag(tag.tag)
}, toDisplayString(tag.tag), 11, _hoisted_3$8); }, toDisplayString(tag.tag), 11, _hoisted_2$9);
}), 128)), }), 128)),
filteredTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$6, " No tags found ")) : createCommentVNode("", true) visibleTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_3$8, " No tags found ")) : createCommentVNode("", true),
]) hasMoreTags.value ? (openBlock(), createElementBlock("div", _hoisted_4$6, " Scroll to load more... ")) : createCommentVNode("", true)
], 544)
]), ]),
_: 1 _: 1
}, 8, ["visible", "title", "subtitle", "modal-class"]); }, 8, ["visible", "title", "subtitle", "modal-class"]);
}; };
} }
}); });
const TagsModal = /* @__PURE__ */ _export_sfc(_sfc_main$d, [["__scopeId", "data-v-110d6f7d"]]); const TagsModal = /* @__PURE__ */ _export_sfc(_sfc_main$d, [["__scopeId", "data-v-48c2535d"]]);
const _hoisted_1$c = { class: "tree-node" }; const _hoisted_1$c = { class: "tree-node" };
const _hoisted_2$8 = { const _hoisted_2$8 = {
key: 1, key: 1,
@@ -11565,7 +11615,7 @@ function useLoraPoolApi() {
return []; return [];
} }
}; };
const fetchTags = async (limit = 100) => { const fetchTags = async (limit = 0) => {
try { try {
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`); const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`);
const data = await response.json(); const data = await response.json();

File diff suppressed because one or more lines are too long