feat: support reverse-proxy URL subpaths (llama-swap, SwarmUI) (#1122)

This commit is contained in:
Will Miao
2026-09-24 08:04:00 +08:00
parent 755e1a5bca
commit 2f9bd3ee7d
42 changed files with 731 additions and 103 deletions
@@ -58,6 +58,7 @@
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import ModalWrapper from '../lora-pool/modals/ModalWrapper.vue'
import type { LoraItem } from '../../composables/types'
import { lmApiUrl } from '@/utils/basePath'
interface LoraListItem {
index: number
@@ -131,7 +132,7 @@ const selectLora = (index: number) => {
// in the Vue widgets build, so we need to use the full path with /api prefix
const customPreviewUrlResolver = async (modelName: string) => {
const response = await fetch(
`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`
lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`)
)
if (!response.ok) {
throw new Error('Failed to fetch preview URL')
@@ -35,6 +35,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { LoraEntry } from '../../composables/types'
import { lmApiUrl } from '@/utils/basePath'
const props = defineProps<{
loras: LoraEntry[]
@@ -48,7 +49,7 @@ const previewUrls = ref<Record<string, string>>({})
// Fetch preview URL for a lora using API
const fetchPreviewUrl = async (loraName: string) => {
try {
const response = await fetch(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`)
const response = await fetch(lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`))
if (response.ok) {
const data = await response.json()
@@ -1,5 +1,6 @@
import { ref, watch, computed } from 'vue'
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
import { lmApiUrl } from '@/utils/basePath'
export interface CyclerLoraItem {
file_name: string
@@ -173,7 +174,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
requestBody.pool_config = poolConfig.filters
}
const response = await fetch('/api/lm/loras/cycler-list', {
const response = await fetch(lmApiUrl('/api/lm/loras/cycler-list'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1,12 +1,13 @@
import { ref } from 'vue'
import type { BaseModelOption, TagOption, FolderTreeNode, LoraItem } from './types'
import { lmApiUrl } from '@/utils/basePath'
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 response = await fetch(lmApiUrl(`/api/lm/loras/base-models?limit=${limit}`))
const data = await response.json()
return data.base_models || []
} catch (error) {
@@ -17,7 +18,7 @@ export function useLoraPoolApi() {
const fetchTags = async (limit = 0): Promise<TagOption[]> => {
try {
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`)
const response = await fetch(lmApiUrl(`/api/lm/loras/top-tags?limit=${limit}`))
const data = await response.json()
return data.tags || []
} catch (error) {
@@ -28,7 +29,7 @@ export function useLoraPoolApi() {
const fetchFolderTree = async (): Promise<FolderTreeNode[]> => {
try {
const response = await fetch('/api/lm/loras/unified-folder-tree')
const response = await fetch(lmApiUrl('/api/lm/loras/unified-folder-tree'))
const data = await response.json()
return transformFolderTree(data.tree || {})
} catch (error) {
@@ -102,7 +103,7 @@ export function useLoraPoolApi() {
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
const response = await fetch(lmApiUrl(`/api/lm/loras/list?${urlParams}`))
const data = await response.json()
return {
@@ -1,5 +1,6 @@
import { ref, computed, watch } from 'vue'
import type { ComponentWidget, RandomizerConfig, LoraEntry } from './types'
import { lmApiUrl } from '@/utils/basePath'
export function useLoraRandomizerState(widget: ComponentWidget<RandomizerConfig>) {
// Flag to prevent infinite loops during config restoration
@@ -160,7 +161,7 @@ export function useLoraRandomizerState(widget: ComponentWidget<RandomizerConfig>
}
// Call API endpoint
const response = await fetch('/api/lm/loras/random-sample', {
const response = await fetch(lmApiUrl('/api/lm/loras/random-sample'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+8
View File
@@ -0,0 +1,8 @@
export function getLmBasePath(): string {
const { pathname } = window.location;
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
}
export function lmApiUrl(path: string): string {
return `${getLmBasePath()}${path}`;
}
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from 'vitest'
import { getLmBasePath, lmApiUrl } from '@/utils/basePath'
const originalPathname = window.location.pathname
function setPathname(pathname: string) {
window.history.replaceState(null, '', pathname)
}
afterEach(() => {
setPathname(originalPathname)
})
describe('getLmBasePath', () => {
it('returns an empty string when ComfyUI is served at the root', () => {
setPathname('/')
expect(getLmBasePath()).toBe('')
})
it('strips the trailing slash from a subpath prefix', () => {
setPathname('/comfyui/')
expect(getLmBasePath()).toBe('/comfyui')
})
it('keeps a prefix without a trailing slash as-is', () => {
setPathname('/ComfyBackendDirect')
expect(getLmBasePath()).toBe('/ComfyBackendDirect')
})
})
describe('lmApiUrl', () => {
it('leaves root-absolute paths unchanged at the root', () => {
setPathname('/')
expect(lmApiUrl('/api/lm/loras/list')).toBe('/api/lm/loras/list')
})
it('prepends the subpath prefix to root-absolute paths', () => {
setPathname('/comfyui/')
expect(lmApiUrl('/api/lm/loras/list')).toBe('/comfyui/api/lm/loras/list')
})
})