mirror of
https://github.com/mostlygeek/llama-swap.git
synced 2026-06-09 06:46:34 +02:00
proxy, ui-svelte: add /sdapi/v1 endpoint support (#587)
Add proxy routes for stable-diffusion.cpp's /sdapi/v1/txt2img, /sdapi/v1/img2img, and /sdapi/v1/loras endpoints. POST endpoints use proxyInferenceHandler (model in JSON body), GET /loras uses proxyGETModelHandler (model in query param). Update the image playground with a dual-mode UI supporting both OpenAI and SDAPI backends. In SDAPI mode, loras are fetched first to prime the server-side cache, and all txt2img parameters are exposed (negative prompt, steps, cfg_scale, seed, batch_size, clip_skip, sampler, scheduler, lora selection with multipliers). - Add 3 sdapi route registrations in proxymanager.go - Add sdApi.ts client with generateSdImage and fetchSdLoras - Add SDAPI types (SdApiTxt2ImgRequest, SdApiResponse, etc.) - Add /sdapi to vite dev proxy config - Add backend tests for sdapi routing - Support batch image display in gallery grid https://claude.ai/code/session_0186MGX6NXdHVBTv2KH45fqn --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
|
||||
# Only run in this linux based runner
|
||||
- name: Check Formatting
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ./build
|
||||
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
|
||||
key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }}
|
||||
|
||||
# necessary for testing proxy/Process swapping
|
||||
- name: Create simple-responder
|
||||
@@ -67,4 +67,4 @@ jobs:
|
||||
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
|
||||
|
||||
- name: Test all
|
||||
run: make test-all
|
||||
run: make test-all
|
||||
|
||||
@@ -274,6 +274,43 @@ func main() {
|
||||
c.String(200, fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path))
|
||||
})
|
||||
|
||||
// SD API endpoints
|
||||
r.POST("/sdapi/v1/txt2img", func(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read request body"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
modelName := gjson.GetBytes(body, "model").String()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": modelName,
|
||||
"images": []string{},
|
||||
})
|
||||
})
|
||||
|
||||
r.POST("/sdapi/v1/img2img", func(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read request body"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
modelName := gjson.GetBytes(body, "model").String()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": modelName,
|
||||
"images": []string{},
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/sdapi/v1/loras", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"loras": []string{},
|
||||
})
|
||||
})
|
||||
|
||||
address := "127.0.0.1:" + *port // Address with the specified port
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/mostlygeek/llama-swap
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/billziss-gh/golib v0.2.0
|
||||
|
||||
@@ -346,6 +346,11 @@ func (pm *ProxyManager) setupGinEngine() {
|
||||
pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
||||
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyOAIPostFormHandler)
|
||||
|
||||
// sd.cpp /sdapi/v1 endpoints
|
||||
pm.ginEngine.POST("/sdapi/v1/txt2img", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
||||
pm.ginEngine.POST("/sdapi/v1/img2img", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
||||
pm.ginEngine.GET("/sdapi/v1/loras", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyGETModelHandler)
|
||||
|
||||
pm.ginEngine.GET("/v1/models", pm.apiKeyAuth(), pm.listModelsHandler)
|
||||
|
||||
// in proxymanager_loghandlers.go
|
||||
|
||||
@@ -1659,3 +1659,82 @@ models:
|
||||
assert.Equal(t, "no", w.Header().Get("X-Accel-Buffering"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestProxyManager_SdApiTxt2ImgRouting(t *testing.T) {
|
||||
conf := config.AddDefaultGroupToConfig(config.Config{
|
||||
HealthCheckTimeout: 15,
|
||||
Models: map[string]config.ModelConfig{
|
||||
"sd-model": getTestSimpleResponderConfig("sd-model"),
|
||||
},
|
||||
LogLevel: "error",
|
||||
})
|
||||
|
||||
proxy := New(conf)
|
||||
defer proxy.StopProcesses(StopWaitForInflightRequest)
|
||||
|
||||
t.Run("successful txt2img with model", func(t *testing.T) {
|
||||
reqBody := `{"model":"sd-model","prompt":"a cat"}`
|
||||
req := httptest.NewRequest("POST", "/sdapi/v1/txt2img", bytes.NewBufferString(reqBody))
|
||||
w := CreateTestResponseRecorder()
|
||||
|
||||
proxy.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "sd-model")
|
||||
})
|
||||
|
||||
t.Run("successful img2img with model", func(t *testing.T) {
|
||||
reqBody := `{"model":"sd-model","prompt":"a cat","init_images":[]}`
|
||||
req := httptest.NewRequest("POST", "/sdapi/v1/img2img", bytes.NewBufferString(reqBody))
|
||||
w := CreateTestResponseRecorder()
|
||||
|
||||
proxy.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "sd-model")
|
||||
})
|
||||
|
||||
t.Run("missing model returns 400", func(t *testing.T) {
|
||||
reqBody := `{"prompt":"a cat"}`
|
||||
req := httptest.NewRequest("POST", "/sdapi/v1/txt2img", bytes.NewBufferString(reqBody))
|
||||
w := CreateTestResponseRecorder()
|
||||
|
||||
proxy.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "missing or invalid 'model' key")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProxyManager_SdApiGetLoras(t *testing.T) {
|
||||
conf := config.AddDefaultGroupToConfig(config.Config{
|
||||
HealthCheckTimeout: 15,
|
||||
Models: map[string]config.ModelConfig{
|
||||
"sd-model": getTestSimpleResponderConfig("sd-model"),
|
||||
},
|
||||
LogLevel: "error",
|
||||
})
|
||||
|
||||
proxy := New(conf)
|
||||
defer proxy.StopProcesses(StopWaitForInflightRequest)
|
||||
|
||||
t.Run("successful GET loras with model query param", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/sdapi/v1/loras?model=sd-model", nil)
|
||||
w := CreateTestResponseRecorder()
|
||||
proxy.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
t.Run("missing model query param returns 400", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/sdapi/v1/loras", nil)
|
||||
w := CreateTestResponseRecorder()
|
||||
proxy.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "missing required 'model' query parameter")
|
||||
})
|
||||
|
||||
t.Run("unknown model returns 400", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/sdapi/v1/loras?model=nonexistent", nil)
|
||||
w := CreateTestResponseRecorder()
|
||||
proxy.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "could not find suitable handler")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,26 +2,91 @@
|
||||
import { models } from "../../stores/api";
|
||||
import { persistentStore } from "../../stores/persistent";
|
||||
import { generateImage } from "../../lib/imageApi";
|
||||
import { generateSdImage, fetchSdLoras } from "../../lib/sdApi";
|
||||
import { playgroundStores } from "../../stores/playgroundActivity";
|
||||
import ModelSelector from "./ModelSelector.svelte";
|
||||
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||
import type { ImageApiMode, SdApiLora, SdApiLoraRef } from "../../lib/types";
|
||||
|
||||
const selectedModelStore = persistentStore<string>("playground-image-model", "");
|
||||
const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024");
|
||||
const apiModeStore = persistentStore<ImageApiMode>("playground-image-api-mode", "openai");
|
||||
|
||||
// SDAPI persistent settings
|
||||
const sdNegativePromptStore = persistentStore<string>("playground-sdapi-negative-prompt", "");
|
||||
const sdStepsStore = persistentStore<number>("playground-sdapi-steps", 20);
|
||||
const sdCfgScaleStore = persistentStore<number>("playground-sdapi-cfg-scale", 7);
|
||||
const sdSeedStore = persistentStore<number>("playground-sdapi-seed", -1);
|
||||
const sdSamplerStore = persistentStore<string>("playground-sdapi-sampler", "");
|
||||
const sdSchedulerStore = persistentStore<string>("playground-sdapi-scheduler", "");
|
||||
const sdBatchSizeStore = persistentStore<number>("playground-sdapi-batch-size", 1);
|
||||
|
||||
let prompt = $state("");
|
||||
let isGenerating = $state(false);
|
||||
let generatedImage = $state<string | null>(null);
|
||||
let generatedImages = $state<string[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let abortController = $state<AbortController | null>(null);
|
||||
let showFullscreen = $state(false);
|
||||
let fullscreenIndex = $state(0);
|
||||
let showSettings = $state(false);
|
||||
|
||||
// SDAPI lora state
|
||||
let availableLoras = $state<SdApiLora[]>([]);
|
||||
let selectedLoras = $state<SdApiLoraRef[]>([]);
|
||||
let isLoadingLoras = $state(false);
|
||||
let lorasLoaded = $state(false);
|
||||
let loraError = $state<string | null>(null);
|
||||
|
||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||
let isSdapi = $derived($apiModeStore === "sdapi");
|
||||
|
||||
$effect(() => {
|
||||
playgroundStores.imageGenerating.set(isGenerating);
|
||||
});
|
||||
|
||||
async function loadLoras() {
|
||||
if (!$selectedModelStore || isLoadingLoras) return;
|
||||
isLoadingLoras = true;
|
||||
loraError = null;
|
||||
try {
|
||||
const loras = await fetchSdLoras($selectedModelStore);
|
||||
availableLoras = loras;
|
||||
lorasLoaded = true;
|
||||
} catch (err) {
|
||||
availableLoras = [];
|
||||
loraError = err instanceof Error ? err.message : "Failed to load LoRAs";
|
||||
lorasLoaded = false;
|
||||
} finally {
|
||||
isLoadingLoras = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addLora(event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const path = select.value;
|
||||
if (!path) return;
|
||||
|
||||
const lora = availableLoras.find((l) => l.path === path);
|
||||
if (lora && !selectedLoras.some((l) => l.path === path)) {
|
||||
selectedLoras = [...selectedLoras, { path: lora.path, multiplier: 1.0 }];
|
||||
}
|
||||
select.value = "";
|
||||
}
|
||||
|
||||
function removeLora(path: string) {
|
||||
selectedLoras = selectedLoras.filter((l) => l.path !== path);
|
||||
}
|
||||
|
||||
function updateLoraMultiplier(path: string, multiplier: number) {
|
||||
selectedLoras = selectedLoras.map((l) =>
|
||||
l.path === path ? { ...l, multiplier } : l
|
||||
);
|
||||
}
|
||||
|
||||
function getLoraName(path: string): string {
|
||||
return availableLoras.find((l) => l.path === path)?.name ?? path;
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
|
||||
@@ -31,19 +96,44 @@
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await generateImage(
|
||||
$selectedModelStore,
|
||||
trimmedPrompt,
|
||||
$selectedSizeStore,
|
||||
abortController.signal
|
||||
);
|
||||
if (isSdapi) {
|
||||
const [w, h] = $selectedSizeStore.split("x").map(Number);
|
||||
const request = {
|
||||
model: $selectedModelStore,
|
||||
prompt: trimmedPrompt,
|
||||
negative_prompt: $sdNegativePromptStore || undefined,
|
||||
width: w,
|
||||
height: h,
|
||||
steps: $sdStepsStore,
|
||||
cfg_scale: $sdCfgScaleStore,
|
||||
seed: $sdSeedStore,
|
||||
batch_size: $sdBatchSizeStore,
|
||||
sampler_name: $sdSamplerStore || undefined,
|
||||
scheduler: $sdSchedulerStore || undefined,
|
||||
lora: selectedLoras.length > 0 ? selectedLoras : undefined,
|
||||
};
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
const imageData = response.data[0];
|
||||
if (imageData.b64_json) {
|
||||
generatedImage = `data:image/png;base64,${imageData.b64_json}`;
|
||||
} else if (imageData.url) {
|
||||
generatedImage = imageData.url;
|
||||
const response = await generateSdImage(request, abortController.signal);
|
||||
if (response.images && response.images.length > 0) {
|
||||
generatedImages = response.images.map(
|
||||
(img) => `data:image/png;base64,${img}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const response = await generateImage(
|
||||
$selectedModelStore,
|
||||
trimmedPrompt,
|
||||
$selectedSizeStore,
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
const imageData = response.data[0];
|
||||
if (imageData.b64_json) {
|
||||
generatedImages = [`data:image/png;base64,${imageData.b64_json}`];
|
||||
} else if (imageData.url) {
|
||||
generatedImages = [imageData.url];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -63,28 +153,29 @@
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
generatedImage = null;
|
||||
generatedImages = [];
|
||||
error = null;
|
||||
prompt = "";
|
||||
}
|
||||
|
||||
function downloadImage() {
|
||||
if (!generatedImage) return;
|
||||
function downloadImage(index: number = 0) {
|
||||
const img = generatedImages[index];
|
||||
if (!img) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = generatedImage;
|
||||
link.download = `generated-image-${Date.now()}.png`;
|
||||
link.href = img;
|
||||
link.download = `generated-image-${Date.now()}-${index}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function openFullscreen() {
|
||||
function openFullscreen(index: number = 0) {
|
||||
fullscreenIndex = index;
|
||||
showFullscreen = true;
|
||||
}
|
||||
|
||||
function closeFullscreen(event?: MouseEvent) {
|
||||
// Only close if clicking the background, not the image
|
||||
if (event && event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
@@ -100,9 +191,19 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Model selector -->
|
||||
<!-- Model selector and mode toggle -->
|
||||
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} />
|
||||
|
||||
<select
|
||||
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$apiModeStore}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="sdapi">SDAPI</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$selectedSizeStore}
|
||||
@@ -123,8 +224,166 @@
|
||||
<option value="1024x1792">1024x1792 (SDXL)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
{#if isSdapi}
|
||||
<button
|
||||
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface hover:bg-secondary-hover transition-colors"
|
||||
onclick={() => showSettings = !showSettings}
|
||||
>
|
||||
{showSettings ? "Hide Settings" : "Settings"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- SDAPI Settings Panel -->
|
||||
{#if isSdapi && showSettings}
|
||||
<div class="shrink-0 mb-4 p-4 rounded border border-gray-200 dark:border-white/10 bg-surface">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-txtsecondary">Steps</span>
|
||||
<input
|
||||
type="number"
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$sdStepsStore}
|
||||
min="1"
|
||||
max="150"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-txtsecondary">CFG Scale</span>
|
||||
<input
|
||||
type="number"
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$sdCfgScaleStore}
|
||||
min="1"
|
||||
max="30"
|
||||
step="0.5"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-txtsecondary">Seed (-1 = random)</span>
|
||||
<input
|
||||
type="number"
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$sdSeedStore}
|
||||
min="-1"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-txtsecondary">Batch Size</span>
|
||||
<input
|
||||
type="number"
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$sdBatchSizeStore}
|
||||
min="1"
|
||||
max="8"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-txtsecondary">Sampler</span>
|
||||
<select
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$sdSamplerStore}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="euler_a">euler_a</option>
|
||||
<option value="euler">euler</option>
|
||||
<option value="heun">heun</option>
|
||||
<option value="dpm2">dpm2</option>
|
||||
<option value="dpmpp2s_a">dpmpp2s_a</option>
|
||||
<option value="dpmpp2m">dpmpp2m</option>
|
||||
<option value="dpmpp2mv2">dpmpp2mv2</option>
|
||||
<option value="ipndm">ipndm</option>
|
||||
<option value="ipndm_v">ipndm_v</option>
|
||||
<option value="lcm">lcm</option>
|
||||
<option value="ddim_trailing">ddim_trailing</option>
|
||||
<option value="tcd">tcd</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-txtsecondary">Scheduler</span>
|
||||
<select
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$sdSchedulerStore}
|
||||
>
|
||||
<option value="">Auto for model</option>
|
||||
<option value="discrete">discrete</option>
|
||||
<option value="karras">karras</option>
|
||||
<option value="exponential">exponential</option>
|
||||
<option value="ays">ays</option>
|
||||
<option value="gits">gits</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 mb-3">
|
||||
<span class="text-xs text-txtsecondary">Negative Prompt</span>
|
||||
<textarea
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary resize-y text-sm"
|
||||
bind:value={$sdNegativePromptStore}
|
||||
rows="2"
|
||||
placeholder="Elements to avoid..."
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<!-- LoRA Selection -->
|
||||
<div>
|
||||
<span class="text-xs text-txtsecondary block mb-1">LoRAs</span>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded border border-gray-200 dark:border-white/10 bg-surface hover:bg-secondary-hover transition-colors disabled:opacity-50"
|
||||
onclick={loadLoras}
|
||||
disabled={!$selectedModelStore || isLoadingLoras}
|
||||
>
|
||||
{isLoadingLoras ? "Loading..." : lorasLoaded ? "Reload LoRAs" : "Load LoRAs"}
|
||||
</button>
|
||||
{#if lorasLoaded && availableLoras.length > 0}
|
||||
<select
|
||||
class="flex-1 px-2 py-1.5 text-sm rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
onchange={addLora}
|
||||
>
|
||||
<option value="">Add a LoRA...</option>
|
||||
{#each availableLoras.filter((l) => !selectedLoras.some((s) => s.path === l.path)) as lora}
|
||||
<option value={lora.path}>{lora.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loraError}
|
||||
<p class="text-xs text-red-500 mb-1">{loraError}</p>
|
||||
{/if}
|
||||
{#if lorasLoaded && availableLoras.length === 0}
|
||||
<p class="text-xs text-txtsecondary">No LoRAs available</p>
|
||||
{/if}
|
||||
{#if selectedLoras.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each selectedLoras as lora}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="flex-1 truncate">{getLoraName(lora.path)}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="w-20 px-1.5 py-1 text-xs rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
value={lora.multiplier}
|
||||
oninput={(e) => updateLoraMultiplier(lora.path, parseFloat((e.target as HTMLInputElement).value) || 1)}
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
/>
|
||||
<button
|
||||
class="px-1.5 py-0.5 text-xs rounded border border-gray-200 dark:border-white/10 hover:bg-red-500 hover:text-white hover:border-red-500 transition-colors"
|
||||
onclick={() => removeLora(lora.path)}
|
||||
aria-label="Remove LoRA"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state for no models configured -->
|
||||
{#if !hasModels}
|
||||
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||
@@ -143,22 +402,50 @@
|
||||
<p class="font-medium">Error</p>
|
||||
<p class="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
{:else if generatedImage}
|
||||
{:else if generatedImages.length > 1}
|
||||
<!-- Grid for multiple images (batch) -->
|
||||
<div class="grid grid-cols-2 gap-2 p-2 w-full h-full overflow-auto">
|
||||
{#each generatedImages as img, i}
|
||||
<div class="relative flex items-center justify-center">
|
||||
<button
|
||||
class="p-0 border-0 bg-transparent cursor-pointer"
|
||||
onclick={() => openFullscreen(i)}
|
||||
aria-label="View fullscreen"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt="AI generated content {i + 1}"
|
||||
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); downloadImage(i); }}
|
||||
aria-label="Download image"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if generatedImages.length === 1}
|
||||
<div class="relative max-w-full max-h-full flex items-center justify-center">
|
||||
<button
|
||||
class="p-0 border-0 bg-transparent cursor-pointer"
|
||||
onclick={openFullscreen}
|
||||
onclick={() => openFullscreen(0)}
|
||||
aria-label="View fullscreen"
|
||||
>
|
||||
<img
|
||||
src={generatedImage}
|
||||
src={generatedImages[0]}
|
||||
alt="AI generated content"
|
||||
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); downloadImage(); }}
|
||||
onclick={(e) => { e.stopPropagation(); downloadImage(0); }}
|
||||
aria-label="Download image"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -198,7 +485,7 @@
|
||||
<button
|
||||
class="btn flex-1 md:flex-none"
|
||||
onclick={clearImage}
|
||||
disabled={!generatedImage && !error && !prompt.trim()}
|
||||
disabled={generatedImages.length === 0 && !error && !prompt.trim()}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -209,7 +496,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen dialog -->
|
||||
{#if showFullscreen && generatedImage}
|
||||
{#if showFullscreen && generatedImages[fullscreenIndex]}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||
onclick={(e) => closeFullscreen(e)}
|
||||
@@ -226,7 +513,7 @@
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={generatedImage}
|
||||
src={generatedImages[fullscreenIndex]}
|
||||
alt="AI generated content"
|
||||
class="max-w-full max-h-full object-contain pointer-events-none"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SdApiTxt2ImgRequest, SdApiResponse, SdApiLora } from "./types";
|
||||
|
||||
export async function generateSdImage(
|
||||
request: SdApiTxt2ImgRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<SdApiResponse> {
|
||||
const response = await fetch("/sdapi/v1/txt2img", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`SDAPI error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchSdLoras(
|
||||
model: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<SdApiLora[]> {
|
||||
const response = await fetch(
|
||||
`/sdapi/v1/loras?model=${encodeURIComponent(model)}`,
|
||||
{ signal }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`SDAPI loras error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -115,6 +115,40 @@ export interface ImageGenerationResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
// SDAPI types (stable-diffusion.cpp)
|
||||
export type ImageApiMode = "openai" | "sdapi";
|
||||
|
||||
export interface SdApiLora {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SdApiLoraRef {
|
||||
path: string;
|
||||
multiplier: number;
|
||||
}
|
||||
|
||||
export interface SdApiTxt2ImgRequest {
|
||||
model?: string;
|
||||
prompt: string;
|
||||
negative_prompt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
steps?: number;
|
||||
cfg_scale?: number;
|
||||
seed?: number;
|
||||
batch_size?: number;
|
||||
sampler_name?: string;
|
||||
scheduler?: string;
|
||||
lora?: SdApiLoraRef[];
|
||||
}
|
||||
|
||||
export interface SdApiResponse {
|
||||
images: string[];
|
||||
parameters: Record<string, unknown>;
|
||||
info: string;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionRequest {
|
||||
file: File;
|
||||
model: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
||||
"/upstream": "http://localhost:8080",
|
||||
"/unload": "http://localhost:8080",
|
||||
"/v1": "http://localhost:8080",
|
||||
"/sdapi": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user