forked from wylab/llama-swap
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: go.mod
|
||||||
|
|
||||||
# Only run in this linux based runner
|
# Only run in this linux based runner
|
||||||
- name: Check Formatting
|
- name: Check Formatting
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: ./build
|
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
|
# necessary for testing proxy/Process swapping
|
||||||
- name: Create simple-responder
|
- name: Create simple-responder
|
||||||
@@ -67,4 +67,4 @@ jobs:
|
|||||||
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
|
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
|
||||||
|
|
||||||
- name: Test all
|
- 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))
|
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
|
address := "127.0.0.1:" + *port // Address with the specified port
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/mostlygeek/llama-swap
|
module github.com/mostlygeek/llama-swap
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/billziss-gh/golib v0.2.0
|
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/generations", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyOAIPostFormHandler)
|
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)
|
pm.ginEngine.GET("/v1/models", pm.apiKeyAuth(), pm.listModelsHandler)
|
||||||
|
|
||||||
// in proxymanager_loghandlers.go
|
// in proxymanager_loghandlers.go
|
||||||
|
|||||||
@@ -1659,3 +1659,82 @@ models:
|
|||||||
assert.Equal(t, "no", w.Header().Get("X-Accel-Buffering"))
|
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 { models } from "../../stores/api";
|
||||||
import { persistentStore } from "../../stores/persistent";
|
import { persistentStore } from "../../stores/persistent";
|
||||||
import { generateImage } from "../../lib/imageApi";
|
import { generateImage } from "../../lib/imageApi";
|
||||||
|
import { generateSdImage, fetchSdLoras } from "../../lib/sdApi";
|
||||||
import { playgroundStores } from "../../stores/playgroundActivity";
|
import { playgroundStores } from "../../stores/playgroundActivity";
|
||||||
import ModelSelector from "./ModelSelector.svelte";
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||||
|
import type { ImageApiMode, SdApiLora, SdApiLoraRef } from "../../lib/types";
|
||||||
|
|
||||||
const selectedModelStore = persistentStore<string>("playground-image-model", "");
|
const selectedModelStore = persistentStore<string>("playground-image-model", "");
|
||||||
const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024");
|
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 prompt = $state("");
|
||||||
let isGenerating = $state(false);
|
let isGenerating = $state(false);
|
||||||
let generatedImage = $state<string | null>(null);
|
let generatedImages = $state<string[]>([]);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let abortController = $state<AbortController | null>(null);
|
let abortController = $state<AbortController | null>(null);
|
||||||
let showFullscreen = $state(false);
|
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 hasModels = $derived($models.some((m) => !m.unlisted));
|
||||||
|
let isSdapi = $derived($apiModeStore === "sdapi");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
playgroundStores.imageGenerating.set(isGenerating);
|
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() {
|
async function generate() {
|
||||||
const trimmedPrompt = prompt.trim();
|
const trimmedPrompt = prompt.trim();
|
||||||
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
|
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
|
||||||
@@ -31,19 +96,44 @@
|
|||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await generateImage(
|
if (isSdapi) {
|
||||||
$selectedModelStore,
|
const [w, h] = $selectedSizeStore.split("x").map(Number);
|
||||||
trimmedPrompt,
|
const request = {
|
||||||
$selectedSizeStore,
|
model: $selectedModelStore,
|
||||||
abortController.signal
|
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 response = await generateSdImage(request, abortController.signal);
|
||||||
const imageData = response.data[0];
|
if (response.images && response.images.length > 0) {
|
||||||
if (imageData.b64_json) {
|
generatedImages = response.images.map(
|
||||||
generatedImage = `data:image/png;base64,${imageData.b64_json}`;
|
(img) => `data:image/png;base64,${img}`
|
||||||
} else if (imageData.url) {
|
);
|
||||||
generatedImage = imageData.url;
|
}
|
||||||
|
} 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) {
|
} catch (err) {
|
||||||
@@ -63,28 +153,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearImage() {
|
function clearImage() {
|
||||||
generatedImage = null;
|
generatedImages = [];
|
||||||
error = null;
|
error = null;
|
||||||
prompt = "";
|
prompt = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadImage() {
|
function downloadImage(index: number = 0) {
|
||||||
if (!generatedImage) return;
|
const img = generatedImages[index];
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = generatedImage;
|
link.href = img;
|
||||||
link.download = `generated-image-${Date.now()}.png`;
|
link.download = `generated-image-${Date.now()}-${index}.png`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFullscreen() {
|
function openFullscreen(index: number = 0) {
|
||||||
|
fullscreenIndex = index;
|
||||||
showFullscreen = true;
|
showFullscreen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeFullscreen(event?: MouseEvent) {
|
function closeFullscreen(event?: MouseEvent) {
|
||||||
// Only close if clicking the background, not the image
|
|
||||||
if (event && event.target !== event.currentTarget) {
|
if (event && event.target !== event.currentTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,9 +191,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<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">
|
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||||
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} />
|
<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
|
<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"
|
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}
|
bind:value={$selectedSizeStore}
|
||||||
@@ -123,8 +224,166 @@
|
|||||||
<option value="1024x1792">1024x1792 (SDXL)</option>
|
<option value="1024x1792">1024x1792 (SDXL)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</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>
|
</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 -->
|
<!-- Empty state for no models configured -->
|
||||||
{#if !hasModels}
|
{#if !hasModels}
|
||||||
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||||
@@ -143,22 +402,50 @@
|
|||||||
<p class="font-medium">Error</p>
|
<p class="font-medium">Error</p>
|
||||||
<p class="text-sm mt-1">{error}</p>
|
<p class="text-sm mt-1">{error}</p>
|
||||||
</div>
|
</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">
|
<div class="relative max-w-full max-h-full flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
class="p-0 border-0 bg-transparent cursor-pointer"
|
class="p-0 border-0 bg-transparent cursor-pointer"
|
||||||
onclick={openFullscreen}
|
onclick={() => openFullscreen(0)}
|
||||||
aria-label="View fullscreen"
|
aria-label="View fullscreen"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={generatedImage}
|
src={generatedImages[0]}
|
||||||
alt="AI generated content"
|
alt="AI generated content"
|
||||||
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
|
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
|
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"
|
aria-label="Download image"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -198,7 +485,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn flex-1 md:flex-none"
|
class="btn flex-1 md:flex-none"
|
||||||
onclick={clearImage}
|
onclick={clearImage}
|
||||||
disabled={!generatedImage && !error && !prompt.trim()}
|
disabled={generatedImages.length === 0 && !error && !prompt.trim()}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -209,7 +496,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fullscreen dialog -->
|
<!-- Fullscreen dialog -->
|
||||||
{#if showFullscreen && generatedImage}
|
{#if showFullscreen && generatedImages[fullscreenIndex]}
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||||
onclick={(e) => closeFullscreen(e)}
|
onclick={(e) => closeFullscreen(e)}
|
||||||
@@ -226,7 +513,7 @@
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img
|
||||||
src={generatedImage}
|
src={generatedImages[fullscreenIndex]}
|
||||||
alt="AI generated content"
|
alt="AI generated content"
|
||||||
class="max-w-full max-h-full object-contain pointer-events-none"
|
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 {
|
export interface AudioTranscriptionRequest {
|
||||||
file: File;
|
file: File;
|
||||||
model: string;
|
model: string;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
|||||||
"/upstream": "http://localhost:8080",
|
"/upstream": "http://localhost:8080",
|
||||||
"/unload": "http://localhost:8080",
|
"/unload": "http://localhost:8080",
|
||||||
"/v1": "http://localhost:8080",
|
"/v1": "http://localhost:8080",
|
||||||
|
"/sdapi": "http://localhost:8080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user