Retire custom image after extension migration

513b8d68f5085d16f678c547682d5c038626f8fa

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

32 files changed, +53 -9839Showing whitespace changes
.gitlab-ci.yml+0 -56
@@ -1,56 +0,0 @@
1stages:
2 - build
3 - publish
4
5build-image:
6 stage: build
7 only:
8 - main
9 image: docker:latest
10 services:
11 - name: docker:dind
12 alias: docker
13 # DNS for nested containers comes from the runner's
14 # GITGUD_RUNNER_UPSTREAM_DNS. --bip avoids network collisions; --mtu
15 # clamps the inner bridge (PMTUD blackhole).
16 command: ["sh", "-c", "DOCKER_TLS_CERTDIR='' exec dockerd-entrypoint.sh --bip=192.168.255.1/24 --mtu=1400"]
17 variables:
18 DOCKER_HOST: tcp://docker:2375
19 script:
20 - for i in $(seq 1 60); do docker info >/dev/null 2>&1 && break; sleep 1; done
21 - docker info >/dev/null
22 - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
23 - docker pull "$CI_REGISTRY_IMAGE:latest" || true
24 - docker build --cache-from "$CI_REGISTRY_IMAGE:latest" --label "org.opencontainers.image.source=$CI_PROJECT_URL" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
25 - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
26 - docker push "$CI_REGISTRY_IMAGE:latest"
27
28# Publishes the sillytavern npm package to this project's GitGudLab npm
29# registry on pushes to release, but only when package.json's version isn't
30# published yet. Authenticates with CI_JOB_TOKEN (accepted by the package
31# registry since gitgudlab 218062e — own project only, pusher needs Push);
32# an NPM_TOKEN project CI variable overrides it.
33publish-npm:
34 stage: publish
35 only:
36 - main
37 image: node:24
38 variables:
39 npm_config_cache: .npm
40 ST_NPM_REGISTRY: git.nas.home.htl-sky.net/api/v1/projects/permissionBRICK/SillyTavern/packages/npm/
41 cache:
42 # v2: the v1 cache accumulated each failed publish's own tarball
43 # (cacache doubles the package every run) — key bump discards it
44 key: npm-v2
45 paths:
46 - .npm
47 script:
48 - echo "//${ST_NPM_REGISTRY}:_authToken=${NPM_TOKEN:-$CI_JOB_TOKEN}" > .npmrc
49 - npm ci --omit=dev --ignore-scripts
50 - VERSION=$(node -p "require('./package.json').version")
51 - |
52 if npm view "sillytavern@${VERSION}" version --registry "https://${ST_NPM_REGISTRY}" >/dev/null 2>&1; then
53 echo "Version ${VERSION} is already published; nothing to do."
54 exit 0
55 fi
56 - npm publish --registry "https://${ST_NPM_REGISTRY}"
.npmignore+0 -3
@@ -14,6 +14,3 @@ access.log
14/public/scripts/extensions/third-party14/public/scripts/extensions/third-party
15/colab15/colab
16.gemini16.gemini
17# CI npm cache lives in the workspace (npm_config_cache: .npm) — never pack it
18/.npm
19/.npmrc
README.md+1 -1
@@ -1,6 +1,6 @@
1# SillyTavern1# SillyTavern
22
3LLM Frontend for Power Users - with a few additional features3LLM Frontend for Power Users
44
5## Resources5## Resources
66
homelab/README.md+9 -8
@@ -7,22 +7,19 @@ changing one, update both (scp to the NAS path + commit here).
7| File | Deployed at | Purpose |7| File | Deployed at | Purpose |
8|---|---|---|8|---|---|---|
9| `whisper-lazy-proxy.py` | `/mnt/user/appdata/whisper-lazy/proxy.py` (container `whisper-lazy`, :10300) | Wyoming lazy proxy: answers HA availability checks from cache, starts `faster-whisper` on demand, stops it after 15 min idle (frees GPU VRAM). |9| `whisper-lazy-proxy.py` | `/mnt/user/appdata/whisper-lazy/proxy.py` (container `whisper-lazy`, :10300) | Wyoming lazy proxy: answers HA availability checks from cache, starts `faster-whisper` on demand, stops it after 15 min idle (frees GPU VRAM). |
10| `runpod-lazy-proxy.py` | `/mnt/user/appdata/runpod-lazy/proxy.py` (container `runpod-lazy`, :8189) | ComfyUI-compatible on-demand proxy for a volumeless RunPod GPU pod: only `/lazy/warmup` starts or provisions, while cold ComfyUI requests return 503 for provider failover. `/lazy/ping` records 20-second frontend heartbeats; the proxy owns the 60-second upstream keepalive cadence and continues it through browser timer pauses up to a 5-minute lease. A NAS-side idle timer terminates the pod 15 minutes after keepalives and other activity stop. Model-set changes requested by Warm up download in place via the worker image's model-manager (port 8189, LRU disk eviction); pods are only recreated when none exists or the image predates the manager. |
11| `runpod-pod.sh` | `/boot/config/runpod-pod.sh` | CLI for the runpod-lazy control API (`status|warmup|shutdown|ping|watch`). |
12| `unraid/my-sillytavern.xml` | `/boot/config/plugins/dockerMan/templates-user/my-sillytavern.xml` | Unraid template for the GitGudLab `:latest` image and persistent config/data/plugin mounts. |10| `unraid/my-sillytavern.xml` | `/boot/config/plugins/dockerMan/templates-user/my-sillytavern.xml` | Unraid template for the GitGudLab `:latest` image and persistent config/data/plugin mounts. |
13| `unraid/bootstrap-sillytavern.sh` | `/boot/config/bootstrap-sillytavern.sh` | Recovery bootstrap used when the container or DockerMan template is absent. |11| `unraid/bootstrap-sillytavern.sh` | `/boot/config/bootstrap-sillytavern.sh` | Recovery bootstrap used when the container or DockerMan template is absent. |
14| `unraid/redeploy-sillytavern.sh` | `/boot/config/redeploy-sillytavern.sh` | Recreates the container through Unraid's own updater so template and update-state bookkeeping stay authoritative. |12| `unraid/redeploy-sillytavern.sh` | `/boot/config/redeploy-sillytavern.sh` | Recreates the container through Unraid's own updater so template and update-state bookkeeping stay authoritative. |
13| `unraid/registry-autoupdate.sh` | `/boot/config/registry-autoupdate.sh` | Polls the configured container tags and invokes their Unraid-managed redeploy scripts when an image changes. |
1514
16## SillyTavern image deployment15## SillyTavern image deployment
1716
18Every push to `main` builds the complete customized source tree in GitGudLab CI17The NAS now runs the official stable release image,
19and publishes both the commit tag and18`ghcr.io/sillytavern/sillytavern:latest`. permissionBRICK features live in
20`git.nas.home.htl-sky.net/permissionbrick/sillytavern:latest`. The image carries19persistent public browser/server extensions instead of a custom image.
21OCI source, revision, and build-time labels so a running container can be tied
22back to the exact commit.
2320
24On Unraid, Community Applications Auto Update has `sillytavern` enabled. A21On Unraid, Community Applications Auto Update has `sillytavern` enabled. A
25shared registry poller additionally checks the GitGudLab tag every two minutes22shared registry poller additionally checks the official tag every two minutes
26and invokes `redeploy-sillytavern.sh` when its image ID changes. Do not point23and invokes `redeploy-sillytavern.sh` when its image ID changes. Do not point
27the DockerMan template at a different image while leaving that poller enabled:24the DockerMan template at a different image while leaving that poller enabled:
28the poller and template would otherwise continually disagree and recreate the25the poller and template would otherwise continually disagree and recreate the
@@ -37,3 +34,7 @@ bind mount. `config.yaml` is a single-file bind mounted at
37Related: the RunPod worker image (ComfyUI + custom nodes + boot-time model34Related: the RunPod worker image (ComfyUI + custom nodes + boot-time model
38downloader) is built from github.com/permissionBRICK/comfyui-runpod-worker35downloader) is built from github.com/permissionBRICK/comfyui-runpod-worker
39(private repo, public GHCR package; mirrored on this GitGudLab).36(private repo, public GHCR package; mirrored on this GitGudLab).
37
38The RunPod lazy proxy, CLI, tests, container image, and Unraid template moved to
39the public `permissionBRICK/ST-RunPodProxy` repository. Image Provider
40Extensions links to that service as its optional RunPod backend.
homelab/runpod-lazy-proxy.py+0 -587
@@ -1,587 +0,0 @@
1#!/usr/bin/env python3
2"""ComfyUI lazy proxy for a volumeless RunPod on-demand pod (catalog edition).
3
4SillyTavern's fallback chain points at this proxy as a normal ComfyUI server.
5Models come from a CATALOG pushed by the ST UI: entries of
6 {"name": display, "value": model filename, "files": [{"dest": "unet/x.gguf", "url": "..."}]}
7The pod downloads exactly the selected entry's files at boot (boot-models.py
8reads MODEL_MANIFEST). Changing the active model with a RUNNING pod downloads
9the new files IN PLACE through the pod's model-manager (port 8189; LRU-evicts
10old models if the disk runs short) - the pod is only recreated when it runs an
11old image without the manager.
12
13Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI):
14 GET /lazy/status -> {"state": red|orange|green, "model": ..., ...}
15 POST /lazy/warmup -> start pod for the active catalog entry
16 POST /lazy/shutdown -> terminate pod now
17 POST /lazy/ping -> record a frontend heartbeat (never starts a pod)
18 POST /lazy/catalog -> {"models": [...], "active": "<value>"} store catalog;
19 never starts or provisions a pod
20
21Start semantics: only POST /lazy/warmup may provision or change models. ComfyUI
22requests return 503 unless a pod is already ready, allowing SillyTavern's image
23target fallback chain to continue without waking this target. Browser heartbeats
24grant a short lease during which this proxy sends keepalive probes on a stable
25backend cadence. The idle timer lives here (NAS side): IDLE_SECONDS after the
26last activity signal the pod is terminated, frontend or no frontend.
27"""
28import json
29import os
30import threading
31import time
32import urllib.error
33import urllib.request
34from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
35
36RUNPOD_KEY = os.environ['RUNPOD_KEY']
37LISTEN_PORT = int(os.environ.get('LISTEN_PORT', '8189'))
38IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
39KEEPALIVE_SECONDS = max(1, int(os.environ.get('KEEPALIVE_SECONDS', '60')))
40FRONTEND_LEASE_SECONDS = max(1, int(os.environ.get('FRONTEND_LEASE_SECONDS', '300')))
41DATACENTERS = [d for d in os.environ.get('DATACENTERS', '').split(',') if d]
42CLOUD_TYPE = os.environ.get('CLOUD_TYPE', 'SECURE')
43IMAGE = os.environ.get('IMAGE', 'ghcr.io/permissionbrick/comfyui-runpod-worker:latest')
44GPU_TYPES = os.environ.get('GPU_TYPES', 'NVIDIA RTX 6000 Ada Generation,NVIDIA L40S,NVIDIA L40,NVIDIA RTX A6000,NVIDIA A40').split(',')
45# The worker image runs the cu12.8 runtime; hosts with older drivers fail at
46# sampling time with "CUDA driver version is insufficient" (host lottery).
47CUDA_VERSIONS = [v for v in os.environ.get('CUDA_VERSIONS', '12.8,12.9,13.0').split(',') if v]
48POD_NAME = os.environ.get('POD_NAME', 'comfyui-lazy')
49START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '1500'))
50HF_TOKEN = os.environ.get('HF_TOKEN', '')
51COMFY_ARGS = os.environ.get('COMFY_ARGS', '--listen 0.0.0.0 --port 8188 --use-pytorch-cross-attention')
52CATALOG_FILE = os.environ.get('CATALOG_FILE', '/app/catalog.json')
53
54state = {
55 'pod_id': None,
56 'model': None, # values_key of the models the pod holds
57 'phase': 'red', # red | orange | green
58 'gpu': None,
59 'since': time.time(),
60 'last': time.monotonic(),
61 'frontend_last_seen': None,
62 'keepalive_last': None,
63 'ensuring': 0, # active ensure_pod waiters (status skips probing then)
64 'control_epoch': 0, # incremented by shutdown to cancel in-flight warmups
65 'lock': threading.Lock(),
66}
67
68
69def log(*args):
70 print(time.strftime('%H:%M:%S'), *args, flush=True)
71
72
73def load_catalog():
74 try:
75 with open(CATALOG_FILE) as f:
76 return json.load(f)
77 except Exception:
78 return {'models': [], 'active': None}
79
80
81def save_catalog(catalog):
82 with open(CATALOG_FILE, 'w') as f:
83 json.dump(catalog, f, indent=2)
84
85
86def catalog_entry(value):
87 if not value:
88 return None
89 for entry in load_catalog().get('models', []):
90 if entry.get('value') == value:
91 return entry
92 return None
93
94
95def active_values():
96 active = load_catalog().get('active')
97 if isinstance(active, str):
98 active = [active]
99 return [a for a in (active or []) if a]
100
101
102def values_key(values):
103 return '+'.join(sorted(values)) if values else ''
104
105
106def key_values(key):
107 return set(k for k in (key or '').split('+') if k)
108
109
110def needed_files(values):
111 files = []
112 for value in values or []:
113 entry = catalog_entry(value)
114 if entry:
115 files.extend(entry.get('files') or [])
116 return files
117
118
119def all_catalog_files():
120 files = []
121 for entry in load_catalog().get('models', []):
122 files.extend(entry.get('files') or [])
123 return files
124
125
126def prefetch_rest(pod_id):
127 """Queues every catalog file on the pod (background, non-priority) so later
128 model switches find the files already on disk."""
129 files = all_catalog_files()
130 if not files:
131 return
132 try:
133 res = mm_request(pod_id, 'POST', '/ensure', {'files': files}, timeout=20)
134 if res.get('queued'):
135 log('background prefetch queued:', ', '.join(res['queued']))
136 except Exception:
137 pass # old image without a model manager
138
139
140def api(method, path, body=None):
141 req = urllib.request.Request(
142 f'https://rest.runpod.io/v1{path}',
143 json.dumps(body).encode() if body is not None else None,
144 {'Authorization': f'Bearer {RUNPOD_KEY}', 'Content-Type': 'application/json'},
145 method=method)
146 with urllib.request.urlopen(req, timeout=60) as resp:
147 data = resp.read()
148 return json.loads(data) if data else {}
149
150
151def find_pod():
152 try:
153 for pod in api('GET', '/pods'):
154 if pod.get('name') == POD_NAME and pod.get('desiredStatus') == 'RUNNING':
155 env = pod.get('env') or {}
156 return pod['id'], env.get('MODEL_KEY'), pod.get('machine', {}).get('gpuTypeId')
157 except Exception as err:
158 log('find_pod failed:', err)
159 return None, None, None
160
161
162def create_pod(values, datacenters=None):
163 files = []
164 for value in values or []:
165 entry = catalog_entry(value)
166 if entry:
167 files.extend(entry.get('files') or [])
168 env = {'HF_TOKEN': HF_TOKEN, 'CIVITAI_TOKEN': os.environ.get('CIVITAI_TOKEN', ''), 'GITHUB_TOKEN': os.environ.get('GITHUB_TOKEN', ''), 'MODEL_KEY': values_key(values)}
169 if files:
170 env['MODEL_MANIFEST'] = json.dumps(files)
171 else:
172 env['MODELS'] = 'all' # legacy fallback when no catalog is configured
173 body = {
174 'name': POD_NAME,
175 'imageName': IMAGE,
176 'gpuTypeIds': GPU_TYPES,
177 'gpuCount': 1,
178 'cloudType': CLOUD_TYPE,
179 'containerDiskInGb': 80,
180 'ports': ['8188/http', '8189/http'],
181 'env': env,
182 # Manager starts FIRST so boot download progress is visible from
183 # outside; the subshell tolerates old images without the script.
184 'dockerStartCmd': ['bash', '-c', f'(python3 /model-manager.py &) ; python3 /boot-models.py && cd /comfyui && exec python main.py {COMFY_ARGS}'],
185 }
186 if datacenters:
187 body['dataCenterIds'] = datacenters
188 if CUDA_VERSIONS:
189 body['allowedCudaVersions'] = CUDA_VERSIONS
190 pod = api('POST', '/pods', body)
191 log(f"pod created: {pod['id']} models={values_key(values) or 'legacy-all'} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr dc={pod.get('machine', {}).get('dataCenterId') or (','.join(datacenters) if datacenters else 'any')}")
192 return pod['id'], pod.get('machine', {}).get('gpuTypeId')
193
194
195def pod_url(pod_id):
196 return f'https://{pod_id}-8188.proxy.runpod.net'
197
198
199def mm_request(pod_id, method, path, obj=None, timeout=15):
200 """Calls the in-pod model manager (port 8189)."""
201 req = urllib.request.Request(
202 f'https://{pod_id}-8189.proxy.runpod.net{path}',
203 json.dumps(obj).encode() if obj is not None else None,
204 {'User-Agent': UA, 'Content-Type': 'application/json'},
205 method=method)
206 with urllib.request.urlopen(req, timeout=timeout) as resp:
207 return json.loads(resp.read() or b'{}')
208
209
210UA = 'Mozilla/5.0 (compatible; runpod-lazy-proxy)'
211
212
213def upstream_ready(pod_id):
214 try:
215 req = urllib.request.Request(pod_url(pod_id) + '/system_stats', headers={'User-Agent': UA})
216 with urllib.request.urlopen(req, timeout=5) as resp:
217 if resp.status == 200:
218 resp.read()
219 return True
220 except Exception:
221 pass
222 return False
223
224
225def terminate(pod_id):
226 try:
227 api('DELETE', f'/pods/{pod_id}')
228 log('pod terminated:', pod_id)
229 except Exception as err:
230 log('terminate failed:', err)
231
232
233def _create_pod_with_retries(values):
234 state['phase'] = 'orange'
235 state['since'] = time.time()
236 last_err = None
237 # Prefer the DATACENTERS list (e.g. US - fast HuggingFace peering for model
238 # downloads); if it has no capacity, fall back to any datacenter.
239 plans = [DATACENTERS, DATACENTERS, None] if DATACENTERS else [None, None, None]
240 for attempt, datacenters in enumerate(plans):
241 try:
242 return create_pod(values, datacenters)
243 except urllib.error.HTTPError as err:
244 last_err = err.read().decode()[:200]
245 scope = 'preferred DCs' if datacenters else 'global'
246 log(f'create_pod attempt {attempt + 1} ({scope}) failed:', last_err)
247 time.sleep(5)
248 state['phase'] = 'red'
249 raise RuntimeError(f'could not create pod: {last_err}')
250
251
252def ensure_pod(values=None, wait=True, control_epoch=None):
253 """Ensures a pod holding the catalog values (None = active selection).
254
255 An existing pod gets missing models downloaded IN PLACE through the in-pod
256 model manager; recreation only happens for old-image pods without one."""
257 def check_cancelled():
258 if control_epoch is not None and control_epoch != state['control_epoch']:
259 raise RuntimeError('pod warmup cancelled')
260
261 values = values or active_values()
262 key = values_key(values)
263 files = needed_files(values)
264 dests = [f['dest'].lstrip('/') for f in files]
265 created = False
266 with state['lock']:
267 check_cancelled()
268 pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu'])
269 if not pod_id:
270 pod_id, have, gpu = find_pod()
271 if not pod_id:
272 pod_id, gpu = _create_pod_with_retries(values)
273 have, created = key, True
274 check_cancelled()
275 state.update({
276 'pod_id': pod_id,
277 'model': have,
278 'gpu': gpu,
279 'last': time.monotonic(),
280 'phase': 'green' if state['phase'] == 'green' else 'orange',
281 })
282
283 if not wait:
284 return pod_id
285 # Freshly created pods boot with the right manifest; existing pods need an
286 # /ensure pushed once the manager answers.
287 ensured = created or not files
288 no_manager_strikes = 0
289 with state['lock']:
290 check_cancelled()
291 state['ensuring'] += 1
292 try:
293 deadline = time.monotonic() + START_TIMEOUT
294 while time.monotonic() < deadline:
295 # Booting/downloading counts as activity, else the reaper would
296 # kill a warming pod mid-download.
297 with state['lock']:
298 check_cancelled()
299 if state['pod_id'] != pod_id:
300 raise RuntimeError('pod replaced while waiting')
301 state['last'] = time.monotonic()
302 if not ensured:
303 try:
304 mm_request(pod_id, 'POST', '/ensure', {'files': files, 'priority': True})
305 except Exception:
306 pass # manager still booting, or old image without one
307 else:
308 ensured = True
309 with state['lock']:
310 check_cancelled()
311 state['model'] = values_key(key_values(state['model']) | set(values))
312 log(f'in-place ensure requested: {key}')
313 ready = upstream_ready(pod_id)
314 models_ok = True
315 if not created and files and ensured:
316 try:
317 mm = mm_request(pod_id, 'GET', '/status', timeout=10)
318 errs = {d: e for d, e in (mm.get('errors') or {}).items() if d in dests}
319 if errs:
320 raise RuntimeError(f'model download failed: {errs}')
321 models_ok = not (set(dests) - set(mm.get('present') or []))
322 except RuntimeError:
323 raise
324 except Exception:
325 models_ok = False
326 if ready and not ensured:
327 no_manager_strikes += 1
328 if (no_manager_strikes >= 3 and key and state['model'] and state['model'] != key):
329 # Comfy is up but there is no manager (old image): last resort.
330 log(f'pod has models={state["model"]}, need {key} - recreating (no model manager)')
331 with state['lock']:
332 check_cancelled()
333 terminate(pod_id)
334 pod_id, gpu = _create_pod_with_retries(values)
335 check_cancelled()
336 state.update({'pod_id': pod_id, 'model': key, 'gpu': gpu, 'last': time.monotonic()})
337 created, ensured = True, True
338 continue
339 if ready and models_ok:
340 with state['lock']:
341 check_cancelled()
342 if state['pod_id'] != pod_id:
343 raise RuntimeError('pod replaced while waiting')
344 if state['phase'] != 'green':
345 state['phase'] = 'green'
346 state['since'] = time.time()
347 log('pod ready:', pod_id)
348 # Green on the needed set; everything else in the catalog
349 # downloads in the background for instant later switches.
350 if state.get('prefetch_pod') != pod_id:
351 state['prefetch_pod'] = pod_id
352 threading.Thread(target=prefetch_rest, args=(pod_id,), daemon=True).start()
353 return pod_id
354 with state['lock']:
355 check_cancelled()
356 if state['phase'] == 'green':
357 state['phase'] = 'orange' # in-place download in progress
358 if state['pod_id'] != pod_id:
359 raise RuntimeError('pod replaced while waiting')
360 time.sleep(5)
361 raise RuntimeError('pod not ready within START_TIMEOUT')
362 finally:
363 with state['lock']:
364 state['ensuring'] -= 1
365
366
367def idle_reaper():
368 while True:
369 time.sleep(30)
370 idle = time.monotonic() - state['last']
371 if idle > IDLE_SECONDS:
372 # A heartbeat may arrive just before the next scheduled backend
373 # probe. Its live lease protects the pod until that probe catches
374 # up, without relying on the browser to refresh the idle clock.
375 if frontend_lease_seconds_left() > 0:
376 continue
377 pod_id = state['pod_id'] or find_pod()[0]
378 if pod_id:
379 log(f'idle {int(idle)}s - terminating pod')
380 terminate(pod_id)
381 if pod_id or state['phase'] != 'red':
382 state.update({'pod_id': None, 'model': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
383
384
385def frontend_lease_seconds_left(now=None):
386 """Returns the remaining frontend heartbeat lease in whole seconds."""
387 now = time.monotonic() if now is None else now
388 last_seen = state['frontend_last_seen']
389 if last_seen is None:
390 return 0
391 return max(0, FRONTEND_LEASE_SECONDS - int(now - last_seen))
392
393
394def maintain_frontend_keepalive(now=None):
395 """Probes and refreshes a ready pod while a frontend heartbeat lease is live."""
396 now = time.monotonic() if now is None else now
397 with state['lock']:
398 pod_id = state['pod_id']
399 lease_active = frontend_lease_seconds_left(now) > 0
400 ready = state['phase'] == 'green' and state['ensuring'] == 0
401 if not pod_id or not lease_active or not ready or not upstream_ready(pod_id):
402 return False
403 with state['lock']:
404 # The network probe runs outside the lock. Re-check that its result still
405 # belongs to the current pod and a still-live frontend lease.
406 if state['pod_id'] != pod_id or frontend_lease_seconds_left(now) <= 0:
407 return False
408 state['last'] = now
409 state['keepalive_last'] = now
410 return True
411
412
413def keepalive_scheduler():
414 """Runs keepalive probes on a monotonic backend cadence without timer drift."""
415 next_tick = time.monotonic() + KEEPALIVE_SECONDS
416 while True:
417 time.sleep(max(0, next_tick - time.monotonic()))
418 maintain_frontend_keepalive()
419 next_tick += KEEPALIVE_SECONDS
420 now = time.monotonic()
421 if next_tick <= now:
422 # Skip missed ticks after a long process pause instead of issuing a
423 # burst of stale keepalives, while keeping future ticks aligned.
424 missed = int((now - next_tick) // KEEPALIVE_SECONDS) + 1
425 next_tick += missed * KEEPALIVE_SECONDS
426
427
428def status_body():
429 pod_id = state['pod_id']
430 previous_phase = state['phase']
431 if state['ensuring'] > 0:
432 # An ensure_pod waiter owns the phase; probing here could report green
433 # mid-download or block the status reply on a dead pod's proxy URL.
434 pass
435 elif pod_id:
436 if upstream_ready(pod_id):
437 state['phase'] = 'green'
438 else:
439 found, have, gpu = find_pod()
440 if found:
441 state.update({'pod_id': found, 'model': have, 'gpu': gpu, 'phase': 'orange'})
442 else:
443 state.update({'pod_id': None, 'model': None, 'gpu': None, 'phase': 'red'})
444 if state['phase'] == 'green' and previous_phase != 'green':
445 state['since'] = time.time()
446 else:
447 found, have, gpu = find_pod()
448 if found:
449 state.update({'pod_id': found, 'model': have, 'gpu': gpu})
450 state['phase'] = 'green' if upstream_ready(found) else 'orange'
451 elif state['phase'] != 'orange':
452 state['phase'] = 'red'
453 return json.dumps({
454 'state': state['phase'],
455 'pod_id': state['pod_id'],
456 'model': state['model'],
457 'active': active_values(),
458 'gpu': state['gpu'],
459 'since': state['since'],
460 'url': pod_url(state['pod_id']) if state['pod_id'] else None,
461 'idle_seconds_left': max(0, IDLE_SECONDS - int(time.monotonic() - state['last'])) if state['pod_id'] else 0,
462 'frontend_lease_seconds_left': frontend_lease_seconds_left(),
463 'keepalive_active': bool(state['pod_id'] and state['phase'] == 'green' and frontend_lease_seconds_left() > 0),
464 }).encode()
465
466
467class Proxy(BaseHTTPRequestHandler):
468 protocol_version = 'HTTP/1.1'
469
470 def log_message(self, fmt, *args):
471 pass
472
473 def _reply(self, code, data, ctype='application/json'):
474 self.send_response(code)
475 self.send_header('Content-Type', ctype or 'application/json')
476 self.send_header('Content-Length', str(len(data)))
477 self.send_header('Access-Control-Allow-Origin', '*')
478 self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
479 self.send_header('Access-Control-Allow-Headers', 'Content-Type')
480 self.end_headers()
481 self.wfile.write(data)
482
483 def do_OPTIONS(self):
484 self._reply(204, b'')
485
486 def _handle(self):
487 length = int(self.headers.get('Content-Length') or 0)
488 body = self.rfile.read(length) if length else None
489 path = self.path.split('?')[0]
490
491 # ---- control API ----
492 if path == '/lazy/status':
493 if self.command != 'GET':
494 return self._reply(405, b'{"error": "method not allowed"}')
495 return self._reply(200, status_body())
496 if path in ('/lazy/ping', '/lazy/warmup', '/lazy/shutdown', '/lazy/catalog') and self.command != 'POST':
497 return self._reply(405, b'{"error": "method not allowed"}')
498 if path == '/lazy/ping':
499 with state['lock']:
500 state['frontend_last_seen'] = time.monotonic()
501 return self._reply(200, b'{"ok": true}')
502 if path == '/lazy/warmup':
503 with state['lock']:
504 control_epoch = state['control_epoch']
505 if state['phase'] != 'green':
506 state['phase'] = 'orange'
507 threading.Thread(target=self._safe_ensure, args=(None, control_epoch), daemon=True).start()
508 return self._reply(200, status_body())
509 if path == '/lazy/shutdown':
510 # Serialize with pod creation/recreation. Incrementing the epoch
511 # also cancels a warmup thread that has not entered its lock yet.
512 with state['lock']:
513 state['control_epoch'] += 1
514 pod_id = state['pod_id'] or find_pod()[0]
515 if pod_id:
516 terminate(pod_id)
517 state.update({'pod_id': None, 'model': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
518 return self._reply(200, status_body())
519 if path == '/lazy/catalog':
520 try:
521 payload = json.loads(body or b'{}')
522 active = payload.get('active')
523 if isinstance(active, str):
524 active = [active]
525 catalog = {'models': payload.get('models', []), 'active': [a for a in (active or []) if a]}
526 save_catalog(catalog)
527 log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}")
528 return self._reply(200, status_body())
529 except Exception as err:
530 return self._reply(400, json.dumps({'error': str(err)}).encode())
531
532 # ---- comfy proxying ----
533 pod_id = state['pod_id']
534 if not pod_id:
535 pod_id, have, gpu = find_pod()
536 if pod_id:
537 state.update({'pod_id': pod_id, 'model': have, 'gpu': gpu, 'phase': 'orange'})
538 ready = pod_id and state['ensuring'] == 0 and upstream_ready(pod_id)
539 if not ready:
540 return self._reply(503, json.dumps({
541 'error': 'RunPod pod is not ready; start it with SillyTavern\'s Warm up control.',
542 }).encode())
543 if state['phase'] != 'green':
544 state.update({'phase': 'green', 'since': time.time()})
545 state['last'] = time.monotonic()
546 try:
547 req = urllib.request.Request(pod_url(pod_id) + self.path, body, method=self.command)
548 req.add_header('User-Agent', UA)
549 for header in ('Content-Type', 'Accept'):
550 if self.headers.get(header):
551 req.add_header(header, self.headers[header])
552 with urllib.request.urlopen(req, timeout=300) as resp:
553 self._reply(resp.status, resp.read(), resp.headers.get('Content-Type'))
554 except urllib.error.HTTPError as err:
555 self._reply(err.code, err.read())
556 except Exception as err:
557 log('upstream error:', err)
558 self._reply(502, json.dumps({'error': str(err)}).encode())
559 finally:
560 state['last'] = time.monotonic()
561
562 def _safe_ensure(self, value, control_epoch):
563 try:
564 ensure_pod(value, control_epoch=control_epoch)
565 except Exception as err:
566 log('provision failed:', err)
567
568 do_GET = _handle
569 do_POST = _handle
570
571
572def main():
573 threading.Thread(target=idle_reaper, daemon=True).start()
574 threading.Thread(target=keepalive_scheduler, daemon=True).start()
575 # Adopt a pre-existing pod after a proxy restart without provisioning or
576 # changing it. Read-only status polls will update its readiness phase.
577 pod_id, have, gpu = find_pod()
578 if pod_id:
579 state.update({'pod_id': pod_id, 'model': have, 'gpu': gpu, 'phase': 'orange', 'last': time.monotonic()})
580 log(f'adopting existing pod {pod_id} (model={have})')
581 server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)
582 log(f'runpod-lazy v6 (manual warmup only) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')
583 server.serve_forever()
584
585
586if __name__ == '__main__':
587 main()
homelab/runpod-pod.sh+0 -15
@@ -1,15 +0,0 @@
1#!/bin/sh
2# CLI for the runpod-lazy proxy: status | warmup | shutdown | ping (renew frontend lease)
3# Usable by anyone (or any AI) without SillyTavern. Proxy owns the pod
4# lifecycle; this only talks to its control API.
5CMD="${1:-status}"
6URL="${RUNPOD_LAZY_URL:-http://127.0.0.1:8189}"
7case "$CMD" in
8 status) curl -s "$URL/lazy/status" ;;
9 warmup) curl -s -X POST "$URL/lazy/warmup" ;;
10 shutdown) curl -s -X POST "$URL/lazy/shutdown" ;;
11 ping) curl -s -X POST "$URL/lazy/ping" ;;
12 watch) while true; do printf '%s ' "$(date +%H:%M:%S)"; curl -s "$URL/lazy/status"; echo; sleep 10; done ;;
13 *) echo "usage: runpod-pod.sh [status|warmup|shutdown|ping|watch]"; exit 1 ;;
14esac
15echo
homelab/tests/test_runpod_lazy_proxy.py+0 -197
@@ -1,197 +0,0 @@
1import http.client
2import importlib.util
3import json
4import os
5import pathlib
6import tempfile
7import threading
8import unittest
9from unittest import mock
10
11
12os.environ.setdefault('RUNPOD_KEY', 'test-key')
13PROXY_PATH = pathlib.Path(__file__).parents[1] / 'runpod-lazy-proxy.py'
14SPEC = importlib.util.spec_from_file_location('runpod_lazy_proxy', PROXY_PATH)
15proxy = importlib.util.module_from_spec(SPEC)
16SPEC.loader.exec_module(proxy)
17
18
19class RunpodLazyProxyTest(unittest.TestCase):
20 def setUp(self):
21 self.temp_dir = tempfile.TemporaryDirectory()
22 proxy.CATALOG_FILE = str(pathlib.Path(self.temp_dir.name) / 'catalog.json')
23 proxy.state.update({
24 'pod_id': None,
25 'model': None,
26 'phase': 'red',
27 'gpu': None,
28 'since': 0,
29 'last': 100,
30 'frontend_last_seen': None,
31 'keepalive_last': None,
32 'ensuring': 0,
33 'control_epoch': 0,
34 })
35 self.find_pod = mock.patch.object(proxy, 'find_pod', return_value=(None, None, None))
36 self.find_pod_mock = self.find_pod.start()
37 self.server = proxy.ThreadingHTTPServer(('127.0.0.1', 0), proxy.Proxy)
38 self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
39 self.server_thread.start()
40
41 def tearDown(self):
42 self.server.shutdown()
43 self.server.server_close()
44 self.server_thread.join()
45 self.find_pod.stop()
46 self.temp_dir.cleanup()
47
48 def request(self, method, path, payload=None):
49 body = json.dumps(payload) if payload is not None else None
50 headers = {'Content-Type': 'application/json'} if body is not None else {}
51 connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=2)
52 connection.request(method, path, body=body, headers=headers)
53 response = connection.getresponse()
54 data = response.read()
55 connection.close()
56 return response.status, json.loads(data or b'{}')
57
58 def test_cold_comfy_requests_fail_without_provisioning(self):
59 with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
60 for method, path, payload in [
61 ('GET', '/system_stats', None),
62 ('GET', '/object_info', None),
63 ('POST', '/prompt', {'prompt': {}}),
64 ]:
65 with self.subTest(path=path):
66 status, body = self.request(method, path, payload)
67 self.assertEqual(status, 503)
68 self.assertIn('Warm up', body['error'])
69
70 ensure_pod.assert_not_called()
71
72 def test_status_ping_and_catalog_do_not_provision_cold_pod(self):
73 with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
74 status, body = self.request('GET', '/lazy/status')
75 self.assertEqual(status, 200)
76 self.assertEqual(body['state'], 'red')
77
78 with mock.patch.object(proxy.time, 'monotonic', return_value=120):
79 status, _ = self.request('POST', '/lazy/ping')
80 self.assertEqual(status, 200)
81 self.assertEqual(proxy.state['last'], 100)
82 self.assertEqual(proxy.state['frontend_last_seen'], 120)
83
84 catalog = {
85 'models': [{'name': 'Test', 'value': 'test.safetensors', 'files': []}],
86 'active': ['test.safetensors'],
87 }
88 status, _ = self.request('POST', '/lazy/catalog', catalog)
89 self.assertEqual(status, 200)
90 self.assertEqual(proxy.load_catalog(), catalog)
91 ensure_pod.assert_not_called()
92
93 def test_status_clears_a_stale_ready_pod_without_restarting_it(self):
94 proxy.state.update({'pod_id': 'stopped-pod', 'phase': 'green', 'last': 100})
95
96 with mock.patch.object(proxy, 'upstream_ready', return_value=False):
97 status, body = self.request('GET', '/lazy/status')
98
99 self.assertEqual(status, 200)
100 self.assertEqual(body['state'], 'red')
101 self.assertIsNone(body['pod_id'])
102
103 status, _ = self.request('POST', '/lazy/ping')
104 self.assertEqual(status, 200)
105 self.assertEqual(proxy.state['last'], 100)
106
107 def test_backend_maintains_keepalive_during_frontend_lease(self):
108 proxy.state.update({
109 'pod_id': 'ready-pod',
110 'phase': 'green',
111 'last': 100,
112 'frontend_last_seen': 100,
113 })
114
115 with mock.patch.object(proxy, 'upstream_ready', return_value=True) as upstream_ready:
116 maintained = proxy.maintain_frontend_keepalive(now=399)
117
118 self.assertTrue(maintained)
119 self.assertEqual(proxy.state['last'], 399)
120 self.assertEqual(proxy.state['keepalive_last'], 399)
121 upstream_ready.assert_called_once_with('ready-pod')
122
123 def test_backend_stops_keepalive_after_frontend_lease_expires(self):
124 proxy.state.update({
125 'pod_id': 'ready-pod',
126 'phase': 'green',
127 'last': 100,
128 'frontend_last_seen': 100,
129 })
130
131 with mock.patch.object(proxy, 'upstream_ready') as upstream_ready:
132 maintained = proxy.maintain_frontend_keepalive(now=401)
133
134 self.assertFalse(maintained)
135 self.assertEqual(proxy.state['last'], 100)
136 self.assertIsNone(proxy.state['keepalive_last'])
137 upstream_ready.assert_not_called()
138
139 def test_get_warmup_is_rejected_without_provisioning(self):
140 with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
141 status, body = self.request('GET', '/lazy/warmup')
142
143 self.assertEqual(status, 405)
144 self.assertEqual(body['error'], 'method not allowed')
145 ensure_pod.assert_not_called()
146
147 def test_warmup_is_the_explicit_provisioning_path(self):
148 called = threading.Event()
149 with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
150 ensure_pod.side_effect = lambda _value, control_epoch: called.set()
151
152 status, body = self.request('POST', '/lazy/warmup')
153
154 self.assertEqual(status, 200)
155 self.assertEqual(body['state'], 'orange')
156 self.assertTrue(called.wait(1))
157 ensure_pod.assert_called_once_with(None, control_epoch=0)
158
159 def test_shutdown_cancels_pod_creation_in_progress(self):
160 create_started = threading.Event()
161 finish_create = threading.Event()
162 shutdown_finished = threading.Event()
163 shutdown_result = {}
164
165 def create_pod(_values):
166 create_started.set()
167 self.assertTrue(finish_create.wait(2))
168 return 'created-after-warmup', 'test-gpu'
169
170 def shutdown_request():
171 shutdown_result['response'] = self.request('POST', '/lazy/shutdown')
172 shutdown_finished.set()
173
174 with mock.patch.object(proxy, '_create_pod_with_retries', side_effect=create_pod), \
175 mock.patch.object(proxy, 'terminate') as terminate:
176 status, body = self.request('POST', '/lazy/warmup')
177 self.assertEqual(status, 200)
178 self.assertEqual(body['state'], 'orange')
179 self.assertTrue(create_started.wait(1))
180
181 shutdown_thread = threading.Thread(target=shutdown_request)
182 shutdown_thread.start()
183 self.assertFalse(shutdown_finished.wait(0.1))
184
185 finish_create.set()
186 shutdown_thread.join(2)
187 self.assertFalse(shutdown_thread.is_alive())
188
189 status, body = shutdown_result['response']
190 self.assertEqual(status, 200)
191 self.assertEqual(body['state'], 'red')
192 self.assertIsNone(proxy.state['pod_id'])
193 terminate.assert_called_once_with('created-after-warmup')
194
195
196if __name__ == '__main__':
197 unittest.main()
homelab/unraid/bootstrap-sillytavern.sh+1 -1
@@ -2,7 +2,7 @@
2# Raw recovery bootstrap. Normal updates go through redeploy-sillytavern.sh.2# Raw recovery bootstrap. Normal updates go through redeploy-sillytavern.sh.
3set -eu3set -eu
44
5REF=git.nas.home.htl-sky.net/permissionbrick/sillytavern:latest5REF=ghcr.io/sillytavern/sillytavern:latest
66
7docker pull "$REF"7docker pull "$REF"
8docker rm -f sillytavern >/dev/null 2>&1 || true8docker rm -f sillytavern >/dev/null 2>&1 || true
homelab/unraid/my-sillytavern.xml+5 -5
@@ -1,17 +1,17 @@
1<?xml version="1.0"?>1<?xml version="1.0"?>
2<Container version="2">2<Container version="2">
3 <Name>sillytavern</Name>3 <Name>sillytavern</Name>
4 <Repository>git.nas.home.htl-sky.net/permissionbrick/sillytavern:latest</Repository>4 <Repository>ghcr.io/sillytavern/sillytavern:latest</Repository>
5 <Registry>https://git.nas.home.htl-sky.net/permissionBRICK/SillyTavern</Registry>5 <Registry>https://github.com/SillyTavern/SillyTavern/pkgs/container/sillytavern</Registry>
6 <Network>bridge</Network>6 <Network>bridge</Network>
7 <MyIP/>7 <MyIP/>
8 <MyMAC/>8 <MyMAC/>
9 <Shell>sh</Shell>9 <Shell>sh</Shell>
10 <Privileged>false</Privileged>10 <Privileged>false</Privileged>
11 <Support>https://github.com/SillyTavern/SillyTavern/issues</Support>11 <Support>https://github.com/SillyTavern/SillyTavern/issues</Support>
12 <Project>https://git.nas.home.htl-sky.net/permissionBRICK/SillyTavern</Project>12 <Project>https://github.com/SillyTavern/SillyTavern</Project>
13 <ReadMe>https://git.nas.home.htl-sky.net/permissionBRICK/SillyTavern</ReadMe>13 <ReadMe>https://github.com/SillyTavern/SillyTavern</ReadMe>
14 <Overview>Full permissionBRICK SillyTavern build from GitGudLab, including RunPod/ComfyUI workflows, image references, OpenRouter per-chat session stickiness, and custom features not yet extracted as extensions. Public third-party extensions remain installed in the persistent user data directory. GitGudLab CI publishes :latest and Unraid automatically pulls and redeploys it.</Overview>14 <Overview>Official SillyTavern release image. permissionBRICK features are installed as persistent public UI/server extensions, so Unraid can update the base application directly from the upstream :latest image.</Overview>
15 <Category>Tools:</Category>15 <Category>Tools:</Category>
16 <WebUI>http://nas.home.htl-sky.net:8000/</WebUI>16 <WebUI>http://nas.home.htl-sky.net:8000/</WebUI>
17 <TemplateURL/>17 <TemplateURL/>
homelab/unraid/registry-autoupdate.sh+34 -0
@@ -0,0 +1,34 @@
1#!/bin/sh
2# Instant auto-update: poll the registry and redeploy a container as soon as
3# its published image differs from what is running. Cron runs this every 2 min
4# (/boot/config/plugins/dynamix/registry-autoupdate.cron).
5# Skips entirely while a CI job container is active: recreating gitgudlab would
6# kill the registry mid-push, and the runner must not recreate itself mid-job
7# (redeploy-gitgudlab-runner.sh also guards this itself).
8
9LOCK=/var/run/registry-autoupdate.lock
10mkdir "$LOCK" 2>/dev/null || exit 0
11trap 'rmdir "$LOCK"' EXIT
12
13if docker ps --format '{{.Names}}' | grep -q '^gitgud-job-'; then
14 exit 0
15fi
16
17check() {
18 name=$1; ref=$2; script=$3
19 docker pull -q "$ref" >/dev/null 2>&1 || return 0
20 latest=$(docker image inspect --format '{{.Id}}' "$ref" 2>/dev/null) || return 0
21 running=$(docker inspect --format '{{.Image}}' "$name" 2>/dev/null)
22 if [ "$latest" != "$running" ]; then
23 logger -t registry-autoupdate "updating $name ($running -> $latest)"
24 if sh "$script" >/dev/null 2>&1; then
25 logger -t registry-autoupdate "$name updated"
26 else
27 logger -t registry-autoupdate "$name redeploy failed or refused, will retry"
28 fi
29 fi
30}
31
32check gitgudlab git.nas.home.htl-sky.net/permissionbrick/gitgudlab:latest /boot/config/redeploy-gitgudlab.sh
33check gitgudlab-runner git.nas.home.htl-sky.net/permissionbrick/gitgudlab:runner-latest /boot/config/redeploy-gitgudlab-runner.sh
34check sillytavern ghcr.io/sillytavern/sillytavern:latest /boot/config/redeploy-sillytavern.sh
public/script.js+3 -35
@@ -4218,7 +4218,6 @@ function removeLastMessage() {
4218 * @property {string} [quietName] Name to use for the quiet prompt (defaults to "System:")4218 * @property {string} [quietName] Name to use for the quiet prompt (defaults to "System:")
4219 * @property {number} [depth] Recursion depth for the generation. Used to prevent infinite loops in tool calls.4219 * @property {number} [depth] Recursion depth for the generation. Used to prevent infinite loops in tool calls.
4220 * @property {JsonSchema} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.4220 * @property {JsonSchema} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.
4221 * @property {boolean} [squashSystemMessages] Apply the configured system-message squashing during a dry run.
4222 */4221 */
42234222
4224/**4223/**
@@ -4229,7 +4228,7 @@ function removeLastMessage() {
4229 * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt4228 * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt
4230 * @returns {Promise<any>} Returns a promise that resolves when the text is done generating.4229 * @returns {Promise<any>} Returns a promise that resolves when the text is done generating.
4231 */4230 */
4232export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, jsonSchema = null, depth = 0, squashSystemMessages = false } = {}, dryRun = false) {4231export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, jsonSchema = null, depth = 0 } = {}, dryRun = false) {
4233 console.log('Generate entered');4232 console.log('Generate entered');
4234 setGenerationProgress(0);4233 setGenerationProgress(0);
4235 generation_started = new Date();4234 generation_started = new Date();
@@ -5241,7 +5240,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
5241 jailbreakPromptOverride: jailbreak,5240 jailbreakPromptOverride: jailbreak,
5242 messages: oaiMessages,5241 messages: oaiMessages,
5243 messageExamples: oaiMessageExamples,5242 messageExamples: oaiMessageExamples,
5244 squashSystemMessages,
5245 }, dryRun);5243 }, dryRun);
5246 generate_data = { prompt: prompt };5244 generate_data = { prompt: prompt };
52475245
@@ -5264,30 +5262,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
5264 return Promise.resolve();5262 return Promise.resolve();
5265 }5263 }
52665264
5267 let activeProviderRequest = null;
5268
5269 async function beginProviderRequest() {
5270 activeProviderRequest = {
5271 id: uuidv4(),
5272 type,
5273 startedAt: Date.now(),
5274 };
5275 await eventSource.emit(event_types.GENERATION_REQUEST_STARTED, activeProviderRequest);
5276 }
5277
5278 async function finishProviderRequest(success) {
5279 if (!activeProviderRequest) {
5280 return;
5281 }
5282 const request = activeProviderRequest;
5283 activeProviderRequest = null;
5284 await eventSource.emit(event_types.GENERATION_REQUEST_FINISHED, {
5285 ...request,
5286 success: !!success,
5287 finishedAt: Date.now(),
5288 });
5289 }
5290
5291 /**5265 /**
5292 * Saves itemized prompt bits and calls streaming or non-streaming generation API.5266 * Saves itemized prompt bits and calls streaming or non-streaming generation API.
5293 * @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>}5267 * @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>}
@@ -5357,7 +5331,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
5357 streamingProcessor.firstMessageText = '';5331 streamingProcessor.firstMessageText = '';
5358 }5332 }
53595333
5360 await beginProviderRequest();
5361 streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema });5334 streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema });
53625335
5363 hideSwipeButtons();5336 hideSwipeButtons();
@@ -5375,7 +5348,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
53755348
5376 const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished;5349 const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished;
5377 const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length;5350 const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length;
5378 await finishProviderRequest(isStreamFinished);
5379 if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) {5351 if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) {
5380 const lastMessage = chat[chat.length - 1];5352 const lastMessage = chat[chat.length - 1];
5381 const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls);5353 const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls);
@@ -5415,10 +5387,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
5415 });5387 });
5416 }5388 }
5417 } else {5389 } else {
5418 await beginProviderRequest();5390 return await sendGenerationRequest(type, generate_data, { jsonSchema });
5419 const data = await sendGenerationRequest(type, generate_data, { jsonSchema });
5420 await finishProviderRequest(!data?.error);
5421 return data;
5422 }5391 }
5423 }5392 }
54245393
@@ -5559,13 +5528,12 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
5559 * @param {Error|object} exception Error or response JSON5528 * @param {Error|object} exception Error or response JSON
5560 * @throws {Error|object} Re-throws the exception5529 * @throws {Error|object} Re-throws the exception
5561 */5530 */
5562 async function onError(exception) {5531 function onError(exception) {
5563 // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message5532 // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message
5564 if (typeof exception?.error?.message === 'string') {5533 if (typeof exception?.error?.message === 'string') {
5565 toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 });5534 toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 });
5566 }5535 }
55675536
5568 await finishProviderRequest(false);
5569 unblockGeneration(type);5537 unblockGeneration(type);
5570 console.log(exception);5538 console.log(exception);
5571 streamingProcessor = null;5539 streamingProcessor = null;
public/scripts/events.js+0 -4
@@ -23,8 +23,6 @@ export const event_types = {
23 GENERATION_STARTED: 'generation_started',23 GENERATION_STARTED: 'generation_started',
24 GENERATION_STOPPED: 'generation_stopped',24 GENERATION_STOPPED: 'generation_stopped',
25 GENERATION_ENDED: 'generation_ended',25 GENERATION_ENDED: 'generation_ended',
26 GENERATION_REQUEST_STARTED: 'generation_request_started',
27 GENERATION_REQUEST_FINISHED: 'generation_request_finished',
28 SD_PROMPT_PROCESSING: 'sd_prompt_processing',26 SD_PROMPT_PROCESSING: 'sd_prompt_processing',
29 EXTENSIONS_FIRST_LOAD: 'extensions_first_load',27 EXTENSIONS_FIRST_LOAD: 'extensions_first_load',
30 EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',28 EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',
@@ -81,8 +79,6 @@ export const event_types = {
81 ONLINE_STATUS_CHANGED: 'online_status_changed',79 ONLINE_STATUS_CHANGED: 'online_status_changed',
82 IMAGE_SWIPED: 'image_swiped',80 IMAGE_SWIPED: 'image_swiped',
83 CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',81 CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
84 CONNECTION_PROFILE_TEMPORARY_STARTED: 'connection_profile_temporary_started',
85 CONNECTION_PROFILE_TEMPORARY_ENDED: 'connection_profile_temporary_ended',
86 CONNECTION_PROFILE_CREATED: 'connection_profile_created',82 CONNECTION_PROFILE_CREATED: 'connection_profile_created',
87 CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',83 CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
88 CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',84 CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
public/scripts/extensions/keepalive/index.js+0 -664
@@ -1,664 +0,0 @@
1import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
2import {
3 Generate,
4 eventSource,
5 event_types,
6 extension_prompt_roles,
7 extension_prompt_types,
8 getGenerateUrl,
9 getRequestHeaders,
10 isGenerating,
11 main_api,
12 saveSettingsDebounced,
13 setExtensionPrompt,
14} from '../../../script.js';
15import { createGenerationParameters, getChatCompletionModel, oai_settings } from '../../openai.js';
16import { horde_settings } from '../../horde.js';
17import { uuidv4 } from '../../utils.js';
18
19export { init };
20
21const MODULE = 'keepalive';
22
23// Cap the keepalive completion to a single token. The full prompt prefix is
24// still processed by the backend (which is what refreshes the server-side
25// cache), but we never pay for a full completion.
26const KEEPALIVE_RESPONSE_LENGTH = 1;
27const HEARTBEAT_INTERVAL_MS = 20000;
28const RETRY_INTERVAL_MS = 5000;
29const SETTINGS_REFRESH_DELAY_MS = 300;
30const MAIN_CHAT_GENERATION_TYPES = new Set(['normal', 'regenerate', 'swipe', 'continue']);
31
32// Extension-prompt key used to inject the keepalive message as a USER message at depth 0.
33const KEEPALIVE_INJECT_ID = 'keepalive_ping';
34
35// Original default message; used to migrate untouched installs to the current default wording.
36const LEGACY_DEFAULT_MESSAGE = 'Keep the cache warm.';
37
38// Idle-tracking key used when no connection profile is active (manual API panel).
39const NONE_KEY = '__none__';
40
41const defaultSettings = {
42 enabled: false,
43 timeoutSeconds: 295,
44 message: '[Note: just reply with an empty response to keep the session alive]',
45 // Per-profile enable map: { [profileId]: boolean }. Unlisted profiles default to enabled.
46 profiles: {},
47};
48
49let heartbeatTimer = null;
50let jobRegistered = false;
51let refreshPromise = null;
52let refreshRequested = false;
53let refreshTimer = null;
54let generationRefreshTimer = null;
55let retryTimer = null;
56let preparingJob = false;
57let lifecycleVersion = 0;
58let temporaryConnectionDepth = 0;
59let refreshAfterTemporaryConnection = false;
60const activeMainRequests = new Map();
61let latestSuccessfulRequestStartedAt = 0;
62// Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new
63// message or starts another foreground generation in the open chat. Merely loading a chat does not
64// prove that its prompt is cached server-side. Reset on page reload and whenever the chat changes.
65let armed = false;
66// Per-profile time of the last verified successful main-chat inference request, keyed by
67// connection profile id (or NONE_KEY). In-memory only; a reload starts disarmed.
68const lastActivity = {};
69// Last successful backend keepalive, keyed by connection profile. The backend returns this after a
70// sleeping tab wakes so we can distinguish a still-warm prompt from an expired one.
71const lastKeepalive = {};
72
73// Per-document ids keep duplicated tabs independent. Jobs orphaned by a closed or reloaded
74// document are deliberately cleaned up by the backend lease.
75const keepaliveJobId = uuidv4().replaceAll('-', '');
76
77/**
78 * @returns {string} The active connection profile id, or NONE_KEY for a manual connection.
79 */
80function getActiveProfileKey() {
81 return extension_settings.connectionManager?.selectedProfile || NONE_KEY;
82}
83
84/**
85 * Whether keepalive should run for the CURRENTLY ACTIVE connection. Requires the global
86 * master switch; for a real profile it also requires that profile's per-profile toggle
87 * (default on). A manual connection (no profile selected) is governed by the master switch alone.
88 * @returns {boolean}
89 */
90function isKeepaliveEnabledForActive() {
91 const s = extension_settings[MODULE];
92 if (!s.enabled) {
93 return false;
94 }
95 const profileId = extension_settings.connectionManager?.selectedProfile;
96 if (!profileId) {
97 return true;
98 }
99 const perProfile = s.profiles?.[profileId];
100 return perProfile === undefined ? true : !!perProfile;
101}
102
103function getTimeoutMs() {
104 return Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000;
105}
106
107function updateLastKeepalive(profileKey, value) {
108 const timestamp = Number(value);
109 if (Number.isFinite(timestamp) && timestamp > 0) {
110 lastKeepalive[profileKey] = Math.max(lastKeepalive[profileKey] || 0, timestamp);
111 }
112}
113
114function getLastWarmTime(profileKey = getActiveProfileKey()) {
115 return Math.max(lastActivity[profileKey] || 0, lastKeepalive[profileKey] || 0);
116}
117
118function isWarmPromptExpired(profileKey = getActiveProfileKey()) {
119 const lastWarmTime = getLastWarmTime(profileKey);
120 return lastWarmTime > 0 && (Date.now() - lastWarmTime) >= getTimeoutMs();
121}
122
123function disarmExpiredJob() {
124 armed = false;
125 jobRegistered = false;
126 refreshRequested = false;
127 lifecycleVersion++;
128 clearTimeout(retryTimer);
129 clearTimeout(refreshTimer);
130 clearTimeout(generationRefreshTimer);
131}
132
133async function postKeepaliveBackend(path, body) {
134 return fetch(`/api/keepalive/${path}`, {
135 method: 'POST',
136 headers: getRequestHeaders(),
137 body: JSON.stringify({ id: keepaliveJobId, ...body }),
138 });
139}
140
141async function stopBackendJob() {
142 lifecycleVersion++;
143 jobRegistered = false;
144 clearTimeout(retryTimer);
145 try {
146 await postKeepaliveBackend('stop', {});
147 } catch (error) {
148 console.debug('[Keepalive] Could not stop backend job:', error);
149 }
150}
151
152async function sendBackendHeartbeat() {
153 if (temporaryConnectionDepth > 0 || !armed || !isKeepaliveEnabledForActive()) {
154 return;
155 }
156 // Registration is normally created as soon as a response finishes. If that one-shot event
157 // was missed (or registration failed), the regular heartbeat must self-heal instead of doing
158 // nothing forever.
159 if (!jobRegistered) {
160 if (isWarmPromptExpired()) {
161 disarmExpiredJob();
162 return;
163 }
164 queueJobRefresh();
165 return;
166 }
167 const version = lifecycleVersion;
168 const profileKey = getActiveProfileKey();
169 try {
170 const response = await postKeepaliveBackend('heartbeat', {});
171 const state = await response.json().catch(() => ({}));
172 if (version !== lifecycleVersion) {
173 return;
174 }
175 updateLastKeepalive(profileKey, state.lastRun);
176 if (response.status === 404) {
177 jobRegistered = false;
178 if (isWarmPromptExpired(profileKey)) {
179 disarmExpiredJob();
180 return;
181 }
182 queueJobRefresh();
183 }
184 } catch (error) {
185 console.debug('[Keepalive] Backend heartbeat failed:', error);
186 }
187}
188
189/**
190 * A new user message may lead to a main-chat request, but does not prove the provider accepted it.
191 * Arm the feature and refresh only the replay payload; the deadline remains unchanged until the
192 * request-success event arrives.
193 */
194function onMessageSent() {
195 armed = true;
196 queueJobRefresh();
197}
198
199/**
200 * Opening or switching chats disarms keepalive: a freshly loaded chat is not proof its prompt is
201 * still cached server-side, so we wait for a new message or foreground generation before running.
202 */
203function onChatChanged() {
204 armed = false;
205 activeMainRequests.clear();
206 latestSuccessfulRequestStartedAt = 0;
207 void stopBackendJob();
208}
209
210/**
211 * Caps a prepared request to a single, non-streaming completion token.
212 * @param {string} endpoint Backend generation endpoint.
213 * @param {object} payload Prepared generation payload.
214 * @returns {object} Cloned and capped payload.
215 */
216function capKeepalivePayload(endpoint, payload) {
217 const capped = structuredClone(payload);
218 capped.stream = false;
219 for (const key of ['max_tokens', 'max_completion_tokens', 'max_length', 'max_new_tokens', 'n_predict']) {
220 if (Object.hasOwn(capped, key)) {
221 capped[key] = KEEPALIVE_RESPONSE_LENGTH;
222 }
223 }
224 if (endpoint === '/api/backends/chat-completions/generate'
225 && !Object.hasOwn(capped, 'max_tokens')
226 && !Object.hasOwn(capped, 'max_completion_tokens')) {
227 capped.max_tokens = KEEPALIVE_RESPONSE_LENGTH;
228 }
229 if (Object.hasOwn(capped, 'n')) {
230 capped.n = 1;
231 }
232 return capped;
233}
234
235/**
236 * Dry-runs prompt assembly and returns a request the backend can replay later.
237 * @returns {Promise<{endpoint: string, payload: object}>}
238 */
239async function prepareKeepaliveRequest() {
240 const api = main_api;
241 let capturedData = null;
242 const captureData = (data, dryRun) => {
243 if (dryRun && preparingJob) {
244 capturedData = structuredClone(data);
245 }
246 };
247
248 preparingJob = true;
249 eventSource.on(event_types.GENERATE_AFTER_DATA, captureData);
250 setExtensionPrompt(KEEPALIVE_INJECT_ID, extension_settings[MODULE].message, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
251 try {
252 // A normal live request applies the user's system-message squashing after prompt assembly.
253 // Opt into the same final transform here so provider cache keys see identical boundaries.
254 await Generate('quiet', { quiet_prompt: '', force_name2: true, squashSystemMessages: true }, true);
255 } finally {
256 setExtensionPrompt(KEEPALIVE_INJECT_ID, '', extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
257 eventSource.removeListener(event_types.GENERATE_AFTER_DATA, captureData);
258 preparingJob = false;
259 }
260
261 if (!capturedData || api !== main_api) {
262 throw new Error('Active connection changed while preparing the keepalive request');
263 }
264
265 let endpoint;
266 let payload;
267 if (api === 'openai') {
268 endpoint = '/api/backends/chat-completions/generate';
269 const model = getChatCompletionModel(oai_settings);
270 ({ generate_data: payload } = await createGenerationParameters(oai_settings, model, 'quiet', capturedData.prompt));
271 await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, payload);
272 } else if (api === 'koboldhorde') {
273 endpoint = '/api/horde/generate-text';
274 const params = structuredClone(capturedData);
275 const prompt = params.prompt;
276 delete params.prompt;
277 Object.assign(params, {
278 n: 1,
279 max_length: KEEPALIVE_RESPONSE_LENGTH,
280 frmtadsnsp: false,
281 frmtrmblln: false,
282 frmtrmspch: false,
283 frmttriminc: false,
284 });
285 payload = {
286 prompt,
287 params,
288 trusted_workers: horde_settings.trusted_workers_only,
289 models: horde_settings.models,
290 };
291 } else {
292 endpoint = getGenerateUrl(api);
293 payload = capturedData;
294 }
295
296 return { endpoint, payload: capKeepalivePayload(endpoint, payload) };
297}
298
299async function registerBackendJob() {
300 if (temporaryConnectionDepth > 0) {
301 refreshAfterTemporaryConnection = true;
302 return;
303 }
304 if (!armed || !isKeepaliveEnabledForActive()) {
305 await stopBackendJob();
306 return;
307 }
308 const profileKey = getActiveProfileKey();
309 if (!getLastWarmTime(profileKey)) {
310 return;
311 }
312 if (isWarmPromptExpired(profileKey)) {
313 disarmExpiredJob();
314 return;
315 }
316 const version = lifecycleVersion;
317 const hadRegisteredJob = jobRegistered;
318 try {
319 if (isGenerating()) {
320 throw new Error('Waiting for the active generation to finish');
321 }
322 const request = await prepareKeepaliveRequest();
323 if (version !== lifecycleVersion || !armed || !isKeepaliveEnabledForActive()) {
324 return;
325 }
326 const timeoutMs = getTimeoutMs();
327 const last = getLastWarmTime(profileKey) || Date.now();
328 const remaining = timeoutMs - (Date.now() - last);
329 if (remaining <= 0) {
330 disarmExpiredJob();
331 return;
332 }
333 const response = await postKeepaliveBackend('register', {
334 ...request,
335 intervalMs: timeoutMs,
336 delayMs: remaining,
337 });
338 if (!response.ok) {
339 throw new Error(`HTTP ${response.status}`);
340 }
341 if (version !== lifecycleVersion || !armed || !isKeepaliveEnabledForActive()) {
342 await stopBackendJob();
343 return;
344 }
345 clearTimeout(retryTimer);
346 jobRegistered = true;
347 } catch (error) {
348 // A failed payload refresh does not mean the existing backend job disappeared. Keeping
349 // this state is especially important while a foreground request is starting, because its
350 // lifecycle handler still needs to pause that existing job before provider dispatch.
351 jobRegistered = hadRegisteredJob;
352 console.debug('[Keepalive] Could not register backend job:', error);
353 clearTimeout(retryTimer);
354 retryTimer = setTimeout(queueJobRefresh, RETRY_INTERVAL_MS);
355 }
356}
357
358function queueJobRefresh() {
359 refreshRequested = true;
360 if (refreshPromise) {
361 return;
362 }
363 refreshPromise = (async () => {
364 while (refreshRequested) {
365 refreshRequested = false;
366 await registerBackendJob();
367 }
368 })().finally(() => {
369 refreshPromise = null;
370 });
371}
372
373function scheduleJobRefresh() {
374 clearTimeout(refreshTimer);
375 refreshTimer = setTimeout(queueJobRefresh, SETTINGS_REFRESH_DELAY_MS);
376}
377
378function scheduleGenerationJobRefresh() {
379 clearTimeout(generationRefreshTimer);
380 generationRefreshTimer = setTimeout(() => {
381 if (armed && isKeepaliveEnabledForActive()) {
382 queueJobRefresh();
383 }
384 }, SETTINGS_REFRESH_DELAY_MS);
385}
386
387function setupHeartbeatLoop() {
388 clearInterval(heartbeatTimer);
389 heartbeatTimer = setInterval(() => void sendBackendHeartbeat(), HEARTBEAT_INTERVAL_MS);
390 document.addEventListener('visibilitychange', () => {
391 if (document.visibilityState === 'visible') {
392 void sendBackendHeartbeat();
393 }
394 });
395}
396
397/**
398 * Renders the per-profile enable checkboxes from the current Connection Manager profiles.
399 */
400function renderProfileToggles() {
401 const container = $('#keepalive_profiles_list');
402 if (!container.length) {
403 return;
404 }
405 container.empty();
406
407 const profiles = extension_settings.connectionManager?.profiles;
408 if (!Array.isArray(profiles) || profiles.length === 0) {
409 container.append(
410 $('<small>')
411 .attr('data-i18n', 'ext_keepalive_no_profiles')
412 .text('No connection profiles found. Keepalive uses the active model, governed by the master toggle above.'),
413 );
414 return;
415 }
416
417 const perProfile = extension_settings[MODULE].profiles || {};
418 const sorted = profiles.slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
419 for (const profile of sorted) {
420 const enabled = perProfile[profile.id] === undefined ? true : !!perProfile[profile.id];
421 const label = $('<label>').addClass('checkbox_label');
422 const input = $('<input>')
423 .attr('type', 'checkbox')
424 .addClass('keepalive_profile_toggle')
425 .attr('data-profile-id', profile.id)
426 .prop('checked', enabled);
427 const span = $('<span>').text(profile.name);
428 label.append(input, span);
429 container.append(label);
430 }
431}
432
433/**
434 * Loads settings into the global store and hydrates the UI controls.
435 */
436function loadSettings() {
437 if (extension_settings[MODULE] === undefined) {
438 extension_settings[MODULE] = {};
439 }
440
441 for (const key of Object.keys(defaultSettings)) {
442 if (extension_settings[MODULE][key] === undefined) {
443 extension_settings[MODULE][key] = structuredClone(defaultSettings[key]);
444 }
445 }
446
447 // Guard against a corrupted/legacy value for the per-profile map.
448 if (typeof extension_settings[MODULE].profiles !== 'object' || extension_settings[MODULE].profiles === null) {
449 extension_settings[MODULE].profiles = {};
450 }
451
452 // Migrate the original default message to the current default so installs that never
453 // customized it pick up the improved wording (custom messages are left untouched).
454 if (extension_settings[MODULE].message === LEGACY_DEFAULT_MESSAGE) {
455 extension_settings[MODULE].message = defaultSettings.message;
456 saveSettingsDebounced();
457 }
458
459 $('#keepalive_enabled').prop('checked', extension_settings[MODULE].enabled);
460 $('#keepalive_timeout').val(extension_settings[MODULE].timeoutSeconds);
461 $('#keepalive_message').val(extension_settings[MODULE].message);
462 renderProfileToggles();
463}
464
465/**
466 * Wires up the settings UI control handlers.
467 */
468function setupListeners() {
469 $('#keepalive_enabled').on('change', function () {
470 extension_settings[MODULE].enabled = !!$(this).prop('checked');
471 saveSettingsDebounced();
472 if (extension_settings[MODULE].enabled && armed) {
473 queueJobRefresh();
474 } else {
475 void stopBackendJob();
476 }
477 });
478
479 $('#keepalive_timeout').on('input', function () {
480 extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295;
481 saveSettingsDebounced();
482 if (armed && isKeepaliveEnabledForActive()) {
483 scheduleJobRefresh();
484 }
485 });
486
487 $('#keepalive_message').on('input', function () {
488 extension_settings[MODULE].message = String($(this).val());
489 saveSettingsDebounced();
490 if (armed && isKeepaliveEnabledForActive()) {
491 scheduleJobRefresh();
492 }
493 });
494
495 // Per-profile toggles are rendered dynamically; use a delegated handler.
496 $('#keepalive_profiles_list').on('change', '.keepalive_profile_toggle', function () {
497 const profileId = $(this).attr('data-profile-id');
498 if (!profileId) {
499 return;
500 }
501 extension_settings[MODULE].profiles[profileId] = !!$(this).prop('checked');
502 saveSettingsDebounced();
503 // If the toggled profile is the one currently active, update its backend job immediately.
504 if (profileId === extension_settings.connectionManager?.selectedProfile) {
505 if (isKeepaliveEnabledForActive() && armed) {
506 queueJobRefresh();
507 } else {
508 void stopBackendJob();
509 }
510 }
511 });
512}
513
514async function pauseBackendJob() {
515 if (!jobRegistered) {
516 return;
517 }
518 try {
519 const response = await postKeepaliveBackend('pause', {});
520 if (response.status === 404) {
521 jobRegistered = false;
522 }
523 } catch (error) {
524 console.debug('[Keepalive] Could not pause backend job:', error);
525 }
526}
527
528async function resumeBackendJob(activityStartedAt = null) {
529 if (!jobRegistered) {
530 return;
531 }
532 const elapsedMs = Number.isFinite(activityStartedAt)
533 ? Math.max(0, Date.now() - activityStartedAt)
534 : null;
535 const profileKey = getActiveProfileKey();
536 try {
537 const response = await postKeepaliveBackend('resume', {
538 activityElapsedMs: elapsedMs,
539 });
540 const state = await response.json().catch(() => ({}));
541 updateLastKeepalive(profileKey, state.lastRun);
542 if (response.status === 404) {
543 jobRegistered = false;
544 }
545 } catch (error) {
546 console.debug('[Keepalive] Could not resume backend job:', error);
547 }
548}
549
550async function onGenerationRequestStarted(request) {
551 if (temporaryConnectionDepth > 0 || !request?.id || !MAIN_CHAT_GENERATION_TYPES.has(request.type)) {
552 return;
553 }
554 armed = true;
555 activeMainRequests.set(request.id, {
556 profileKey: getActiveProfileKey(),
557 startedAt: Number(request.startedAt) || Date.now(),
558 });
559 if (activeMainRequests.size === 1) {
560 await pauseBackendJob();
561 }
562}
563
564async function onGenerationRequestFinished(request) {
565 const activeRequest = activeMainRequests.get(request?.id);
566 if (!activeRequest) {
567 return;
568 }
569 activeMainRequests.delete(request.id);
570
571 if (request.success) {
572 const startedAt = activeRequest.startedAt;
573 lastActivity[activeRequest.profileKey] = Math.max(lastActivity[activeRequest.profileKey] || 0, startedAt);
574 latestSuccessfulRequestStartedAt = Math.max(latestSuccessfulRequestStartedAt, startedAt);
575 armed = true;
576 }
577
578 if (activeMainRequests.size > 0) {
579 return;
580 }
581
582 const successfulStartedAt = latestSuccessfulRequestStartedAt || null;
583 latestSuccessfulRequestStartedAt = 0;
584 await resumeBackendJob(successfulStartedAt);
585
586 // A failed request may still have changed local chat content, so refresh the replay payload,
587 // but registerBackendJob() will retain the old deadline. A successful request supplies the
588 // verified activity timestamp used for the new deadline.
589 scheduleGenerationJobRefresh();
590}
591
592function onChatContentChanged() {
593 // Editing, deleting, or swiping local chat content does not contact the text provider and
594 // therefore cannot refresh its prompt cache. Rebuild the replay payload, but preserve the
595 // deadline from the last real text generation/keepalive. This also keeps image-message edits
596 // and gallery activity from postponing text keepalives.
597 if (armed && isKeepaliveEnabledForActive()) {
598 queueJobRefresh();
599 }
600}
601
602async function onConnectionChanged() {
603 if (temporaryConnectionDepth > 0) {
604 return;
605 }
606 // Remove the previous connection's request before assembling one with the new settings.
607 await stopBackendJob();
608 if (armed && isKeepaliveEnabledForActive()) {
609 queueJobRefresh();
610 }
611}
612
613function onTemporaryConnectionStarted() {
614 temporaryConnectionDepth++;
615}
616
617function onTemporaryConnectionEnded() {
618 temporaryConnectionDepth = Math.max(0, temporaryConnectionDepth - 1);
619 if (temporaryConnectionDepth === 0 && refreshAfterTemporaryConnection) {
620 refreshAfterTemporaryConnection = false;
621 queueJobRefresh();
622 }
623}
624
625async function init() {
626 const settingsHtml = await renderExtensionTemplateAsync(MODULE, 'settings');
627 $('#extensions_settings2').append(settingsHtml);
628
629 loadSettings();
630 setupListeners();
631 setupHeartbeatLoop();
632
633 // A user message arms keepalive, but only the exact provider-request lifecycle below is allowed
634 // to move its deadline. Regenerate/swipe/continue requests arm it when their request starts.
635 eventSource.on(event_types.MESSAGE_SENT, onMessageSent);
636
637 // Only actual text-provider generations reset the cache deadline. Local chat edits still
638 // refresh the stored request so the server replays the latest prompt without pretending that
639 // those edits refreshed the provider cache.
640 eventSource.on(event_types.GENERATION_REQUEST_STARTED, onGenerationRequestStarted);
641 eventSource.on(event_types.GENERATION_REQUEST_FINISHED, onGenerationRequestFinished);
642 eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged);
643 eventSource.on(event_types.MESSAGE_DELETED, onChatContentChanged);
644 eventSource.on(event_types.MESSAGE_UPDATED, onChatContentChanged);
645 eventSource.on(event_types.MESSAGE_SWIPED, onChatContentChanged);
646
647 // Opening/switching a chat disarms keepalive — it must be re-armed by a new sent message.
648 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
649
650 // When the active profile changes, replace the backend job without marking it as activity.
651 // Temporary profile switches used by background image-prompt generation are explicitly ignored.
652 eventSource.on(event_types.CONNECTION_PROFILE_TEMPORARY_STARTED, onTemporaryConnectionStarted);
653 eventSource.on(event_types.CONNECTION_PROFILE_TEMPORARY_ENDED, onTemporaryConnectionEnded);
654 eventSource.on(event_types.CONNECTION_PROFILE_LOADED, onConnectionChanged);
655 eventSource.on(event_types.MAIN_API_CHANGED, onConnectionChanged);
656 eventSource.on(event_types.CHATCOMPLETION_SOURCE_CHANGED, onConnectionChanged);
657 eventSource.on(event_types.CHATCOMPLETION_MODEL_CHANGED, onConnectionChanged);
658 eventSource.on(event_types.PRESET_CHANGED, onConnectionChanged);
659
660 // Keep the per-profile toggle list in sync with Connection Manager.
661 eventSource.on(event_types.CONNECTION_PROFILE_CREATED, renderProfileToggles);
662 eventSource.on(event_types.CONNECTION_PROFILE_UPDATED, renderProfileToggles);
663 eventSource.on(event_types.CONNECTION_PROFILE_DELETED, renderProfileToggles);
664}
public/scripts/extensions/keepalive/manifest.json+0 -14
@@ -1,14 +0,0 @@
1{
2 "display_name": "Cache Keepalive",
3 "loading_order": 100,
4 "requires": [],
5 "optional": [],
6 "js": "index.js",
7 "css": "",
8 "author": "SillyTavern",
9 "version": "1.1.6",
10 "homePage": "https://github.com/SillyTavern/SillyTavern",
11 "hooks": {
12 "activate": "init"
13 }
14}
public/scripts/extensions/keepalive/settings.html+0 -28
@@ -1,28 +0,0 @@
1<div id="keepalive_settings">
2 <div class="inline-drawer">
3 <div class="inline-drawer-toggle inline-drawer-header">
4 <b data-i18n="ext_keepalive_title">Cache Keepalive</b>
5 <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
6 </div>
7 <div class="inline-drawer-content">
8 <label class="checkbox_label" for="keepalive_enabled">
9 <input id="keepalive_enabled" type="checkbox" />
10 <span data-i18n="ext_keepalive_enable">Enable cache keepalive (master switch)</span>
11 </label>
12
13 <label for="keepalive_timeout" data-i18n="ext_keepalive_timeout">Idle timeout (seconds)</label>
14 <input id="keepalive_timeout" class="text_pole" type="number" min="60" step="1" />
15
16 <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label>
17 <input id="keepalive_message" class="text_pole" type="text" />
18
19 <small data-i18n="ext_keepalive_help">After this many seconds without activity on the active connection profile, the current chat plus this message (sent as a hidden user message) is submitted to that profile to keep its server-side prompt cache warm. The completion is capped to a single token and is never saved to the chat. The server owns the keepalive timer, while this tab renews its lease every 20 seconds; if the tab sleeps or closes, the server continues on schedule for up to five minutes after the last heartbeat. Waking a tab after both its lease and cache window have expired does not restart keepalives until a new message, regenerate, swipe, or continue. Only the profile you're currently using is pinged. Merely opening, switching to, or reloading a chat is not proof its prompt is cached.</small>
20
21 <hr>
22
23 <label data-i18n="ext_keepalive_profiles_label">Keepalive per connection profile</label>
24 <small data-i18n="ext_keepalive_profiles_desc">Turn keepalive on only for the profiles whose providers support prompt caching (leave the others off so you don't waste requests). The master switch above must be enabled. Profiles default to on.</small>
25 <div id="keepalive_profiles_list" class="keepalive_profiles_list"></div>
26 </div>
27 </div>
28</div>
public/scripts/extensions/memory/index.js+0 -1299
@@ -1,1299 +0,0 @@
1import { getStringHash, debounce, waitUntilCondition, extractAllWords, isTrueBoolean } from '../../utils.js';
2import { getContext, getApiUrl, extension_settings, doExtrasFetch, modules, renderExtensionTemplateAsync } from '../../extensions.js';
3import {
4 activateSendButtons,
5 deactivateSendButtons,
6 animation_duration,
7 eventSource,
8 event_types,
9 extension_prompt_roles,
10 extension_prompt_types,
11 generateQuietPrompt,
12 is_send_press,
13 online_status,
14 saveSettingsDebounced,
15 substituteParamsExtended,
16 generateRaw,
17 getMaxPromptTokens,
18 setExtensionPrompt,
19 streamingProcessor,
20 animation_easing,
21} from '../../../script.js';
22import { is_group_generating, selected_group } from '../../group-chats.js';
23import { loadMovingUIState, power_user } from '../../power-user.js';
24import { dragElement } from '../../RossAscends-mods.js';
25import { getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';
26import { debounce_timeout } from '../../constants.js';
27import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
28import { SlashCommand } from '../../slash-commands/SlashCommand.js';
29import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
30import { macros, MacroCategory } from '../../macros/macro-system.js';
31import { ConnectionManagerRequestService, countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js';
32import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
33import { removeReasoningFromString } from '../../reasoning.js';
34import { MacrosParser } from '/scripts/macros.js';
35export { MODULE_NAME };
36
37const MODULE_NAME = '1_memory';
38
39let lastMessageHash = null;
40let lastMessageId = null;
41let inApiCall = false;
42
43/**
44 * Count the number of tokens in the provided text.
45 * @param {string} text Text to count tokens for
46 * @param {number} padding Number of additional tokens to add to the count
47 * @returns {Promise<number>} Number of tokens in the text
48 */
49async function countSourceTokens(text, padding = 0) {
50 if (extension_settings.memory.source === summary_sources.webllm) {
51 const count = await countWebLlmTokens(text);
52 return count + padding;
53 }
54
55 if (extension_settings.memory.source === summary_sources.extras) {
56 const count = getTextTokens(tokenizers.GPT2, text).length;
57 return count + padding;
58 }
59
60 return await getTokenCountAsync(text, padding);
61}
62
63async function getSourceContextSize() {
64 const overrideLength = extension_settings.memory.overrideResponseLength;
65
66 if (extension_settings.memory.source === summary_sources.webllm) {
67 const maxContext = await getWebLlmContextSize();
68 return overrideLength > 0 ? (maxContext - overrideLength) : Math.round(maxContext * 0.75);
69 }
70
71 if (extension_settings.source === summary_sources.extras) {
72 return 1024 - 64;
73 }
74
75 return getMaxPromptTokens(overrideLength);
76}
77
78const formatMemoryValue = function (value) {
79 if (!value) {
80 return '';
81 }
82
83 value = value.trim();
84
85 if (extension_settings.memory.template) {
86 return substituteParamsExtended(extension_settings.memory.template, { summary: value });
87 } else {
88 return `Summary: ${value}`;
89 }
90};
91
92const saveChatDebounced = debounce(() => getContext().saveChat(), debounce_timeout.relaxed);
93
94const summary_sources = {
95 'extras': 'extras',
96 'main': 'main',
97 'webllm': 'webllm',
98};
99
100const prompt_builders = {
101 DEFAULT: 0,
102 RAW_BLOCKING: 1,
103 RAW_NON_BLOCKING: 2,
104};
105
106const defaultPrompt = 'Ignore previous instructions. Summarize the most important facts and events in the story so far. If a summary already exists in your memory, use that as a base and expand with new facts. Limit the summary to {{words}} words or less. Your response should include nothing but the summary.';
107const defaultTemplate = '[Summary: {{summary}}]';
108
109const defaultSettings = {
110 memoryFrozen: false,
111 SkipWIAN: false,
112 source: summary_sources.extras,
113 prompt: defaultPrompt,
114 template: defaultTemplate,
115 position: extension_prompt_types.IN_PROMPT,
116 role: extension_prompt_roles.SYSTEM,
117 scan: false,
118 depth: 2,
119 promptWords: 200,
120 promptMinWords: 25,
121 promptMaxWords: 1000,
122 promptWordsStep: 25,
123 promptInterval: 10,
124 promptMinInterval: 0,
125 promptMaxInterval: 250,
126 promptIntervalStep: 1,
127 promptForceWords: 0,
128 promptForceWordsStep: 100,
129 promptMinForceWords: 0,
130 promptMaxForceWords: 10000,
131 overrideResponseLength: 0,
132 overrideResponseLengthMin: 0,
133 overrideResponseLengthMax: 4096,
134 overrideResponseLengthStep: 16,
135 maxMessagesPerRequest: 0,
136 maxMessagesPerRequestMin: 0,
137 maxMessagesPerRequestMax: 250,
138 maxMessagesPerRequestStep: 1,
139 prompt_builder: prompt_builders.DEFAULT,
140 summaryPromptRole: extension_prompt_roles.SYSTEM,
141 summaryConnectionProfile: '',
142};
143
144/**
145 * Resolve the configured role for the summarization request prompt.
146 * Only SYSTEM and USER are supported; anything else falls back to SYSTEM.
147 * @returns {number} One of extension_prompt_roles.SYSTEM or extension_prompt_roles.USER
148 */
149function getSummaryPromptRole() {
150 return Number(extension_settings.memory.summaryPromptRole) === extension_prompt_roles.USER
151 ? extension_prompt_roles.USER
152 : extension_prompt_roles.SYSTEM;
153}
154
155let summaryConnectionProfileDropdownInitialized = false;
156
157/**
158 * Populates the dedicated summarization connection profile dropdown.
159 * Only initializes once to avoid attaching duplicate Connection Manager event listeners
160 * when loadSettings() is called again (e.g. after loading a settings preset).
161 */
162function initSummaryConnectionProfileDropdown() {
163 if (summaryConnectionProfileDropdownInitialized) {
164 return;
165 }
166
167 try {
168 ConnectionManagerRequestService.handleDropdown(
169 '#memory_summary_connection_profile',
170 extension_settings.memory.summaryConnectionProfile,
171 (profile) => {
172 extension_settings.memory.summaryConnectionProfile = profile?.id ?? '';
173 saveSettingsDebounced();
174 },
175 );
176 summaryConnectionProfileDropdownInitialized = true;
177 } catch (error) {
178 // Connection Manager may be unavailable/disabled; leave the dropdown empty in that case.
179 console.warn('Summarize: could not populate summary connection profile dropdown', error);
180 }
181}
182
183function loadSettings() {
184 if (Object.keys(extension_settings.memory).length === 0) {
185 Object.assign(extension_settings.memory, defaultSettings);
186 }
187
188 for (const key of Object.keys(defaultSettings)) {
189 if (extension_settings.memory[key] === undefined) {
190 extension_settings.memory[key] = defaultSettings[key];
191 }
192 }
193
194 $('#summary_source').val(extension_settings.memory.source).trigger('change');
195 $('#memory_frozen').prop('checked', extension_settings.memory.memoryFrozen).trigger('input');
196 $('#memory_skipWIAN').prop('checked', extension_settings.memory.SkipWIAN).trigger('input');
197 $('#memory_prompt').val(extension_settings.memory.prompt).trigger('input');
198 $('#memory_prompt_words').val(extension_settings.memory.promptWords).trigger('input');
199 $('#memory_prompt_interval').val(extension_settings.memory.promptInterval).trigger('input');
200 $('#memory_template').val(extension_settings.memory.template).trigger('input');
201 $('#memory_depth').val(extension_settings.memory.depth).trigger('input');
202 $('#memory_role').val(extension_settings.memory.role).trigger('input');
203 $('#memory_summary_prompt_role').val(extension_settings.memory.summaryPromptRole).trigger('input');
204 initSummaryConnectionProfileDropdown();
205 $(`input[name="memory_position"][value="${extension_settings.memory.position}"]`).prop('checked', true).trigger('input');
206 $('#memory_prompt_words_force').val(extension_settings.memory.promptForceWords).trigger('input');
207 $(`input[name="memory_prompt_builder"][value="${extension_settings.memory.prompt_builder}"]`).prop('checked', true).trigger('input');
208 $('#memory_override_response_length').val(extension_settings.memory.overrideResponseLength).trigger('input');
209 $('#memory_max_messages_per_request').val(extension_settings.memory.maxMessagesPerRequest).trigger('input');
210 $('#memory_include_wi_scan').prop('checked', extension_settings.memory.scan).trigger('input');
211 switchSourceControls(extension_settings.memory.source);
212}
213
214async function onPromptForceWordsAutoClick() {
215 const context = getContext();
216 const maxPromptLength = await getSourceContextSize();
217 const chat = context.chat;
218 const allMessages = chat.filter(m => !m.is_system && m.mes).map(m => m.mes);
219 const messagesWordCount = allMessages.map(m => extractAllWords(m)).flat().length;
220 const averageMessageWordCount = messagesWordCount / allMessages.length;
221 const tokensPerWord = await countSourceTokens(allMessages.join('\n')) / messagesWordCount;
222 const wordsPerToken = 1 / tokensPerWord;
223 const maxPromptLengthWords = Math.round(maxPromptLength * wordsPerToken);
224 // How many words should pass so that messages will start be dropped out of context;
225 const wordsPerPrompt = Math.floor(maxPromptLength / tokensPerWord);
226 // How many words will be needed to fit the allowance buffer
227 const summaryPromptWords = extractAllWords(extension_settings.memory.prompt).length;
228 const promptAllowanceWords = maxPromptLengthWords - extension_settings.memory.promptWords - summaryPromptWords;
229 const averageMessagesPerPrompt = Math.floor(promptAllowanceWords / averageMessageWordCount);
230 const maxMessagesPerSummary = extension_settings.memory.maxMessagesPerRequest || 0;
231 const targetMessagesInPrompt = maxMessagesPerSummary > 0 ? maxMessagesPerSummary : Math.max(0, averageMessagesPerPrompt);
232 const targetSummaryWords = (targetMessagesInPrompt * averageMessageWordCount) + (promptAllowanceWords / 4);
233
234 console.table({
235 maxPromptLength,
236 maxPromptLengthWords,
237 promptAllowanceWords,
238 averageMessagesPerPrompt,
239 targetMessagesInPrompt,
240 targetSummaryWords,
241 wordsPerPrompt,
242 wordsPerToken,
243 tokensPerWord,
244 messagesWordCount,
245 });
246
247 const ROUNDING = 100;
248 extension_settings.memory.promptForceWords = Math.max(1, Math.floor(targetSummaryWords / ROUNDING) * ROUNDING);
249 $('#memory_prompt_words_force').val(extension_settings.memory.promptForceWords).trigger('input');
250}
251
252async function onPromptIntervalAutoClick() {
253 const context = getContext();
254 const maxPromptLength = await getSourceContextSize();
255 const chat = context.chat;
256 const allMessages = chat.filter(m => !m.is_system && m.mes).map(m => m.mes);
257 const messagesWordCount = allMessages.map(m => extractAllWords(m)).flat().length;
258 const messagesTokenCount = await countSourceTokens(allMessages.join('\n'));
259 const tokensPerWord = messagesTokenCount / messagesWordCount;
260 const averageMessageTokenCount = messagesTokenCount / allMessages.length;
261 const targetSummaryTokens = Math.round(extension_settings.memory.promptWords * tokensPerWord);
262 const promptTokens = await countSourceTokens(extension_settings.memory.prompt);
263 const promptAllowance = maxPromptLength - promptTokens - targetSummaryTokens;
264 const maxMessagesPerSummary = extension_settings.memory.maxMessagesPerRequest || 0;
265 const averageMessagesPerPrompt = Math.floor(promptAllowance / averageMessageTokenCount);
266 const targetMessagesInPrompt = maxMessagesPerSummary > 0 ? maxMessagesPerSummary : Math.max(0, averageMessagesPerPrompt);
267 const adjustedAverageMessagesPerPrompt = targetMessagesInPrompt + (averageMessagesPerPrompt - targetMessagesInPrompt) / 4;
268
269 console.table({
270 maxPromptLength,
271 promptAllowance,
272 targetSummaryTokens,
273 promptTokens,
274 messagesWordCount,
275 messagesTokenCount,
276 tokensPerWord,
277 averageMessageTokenCount,
278 averageMessagesPerPrompt,
279 targetMessagesInPrompt,
280 adjustedAverageMessagesPerPrompt,
281 maxMessagesPerSummary,
282 });
283
284 const ROUNDING = 5;
285 extension_settings.memory.promptInterval = Math.max(1, Math.floor(adjustedAverageMessagesPerPrompt / ROUNDING) * ROUNDING);
286
287 $('#memory_prompt_interval').val(extension_settings.memory.promptInterval).trigger('input');
288}
289
290function onSummarySourceChange(event) {
291 const value = event.target.value;
292 extension_settings.memory.source = value;
293 switchSourceControls(value);
294 saveSettingsDebounced();
295}
296
297function switchSourceControls(value) {
298 $('#summaryExtensionDrawerContents [data-summary-source], #memory_settings [data-summary-source]').each((_, element) => {
299 const source = element.dataset.summarySource.split(',').map(s => s.trim());
300 $(element).toggle(source.includes(value));
301 });
302}
303
304function onMemoryFrozenInput() {
305 const value = Boolean($(this).prop('checked'));
306 extension_settings.memory.memoryFrozen = value;
307 saveSettingsDebounced();
308}
309
310function onMemorySkipWIANInput() {
311 const value = Boolean($(this).prop('checked'));
312 extension_settings.memory.SkipWIAN = value;
313 saveSettingsDebounced();
314}
315
316function onMemoryPromptWordsInput() {
317 const value = $(this).val();
318 extension_settings.memory.promptWords = Number(value);
319 $('#memory_prompt_words_value').text(extension_settings.memory.promptWords);
320 saveSettingsDebounced();
321}
322
323function onMemoryPromptIntervalInput() {
324 const value = $(this).val();
325 extension_settings.memory.promptInterval = Number(value);
326 $('#memory_prompt_interval_value').text(extension_settings.memory.promptInterval);
327 saveSettingsDebounced();
328}
329
330function onMemoryPromptRestoreClick() {
331 $('#memory_prompt').val(defaultPrompt).trigger('input');
332}
333
334function onMemoryPromptInput() {
335 const value = $(this).val();
336 extension_settings.memory.prompt = value;
337 saveSettingsDebounced();
338}
339
340function onMemoryTemplateInput() {
341 const value = $(this).val();
342 extension_settings.memory.template = value;
343 reinsertMemory();
344 saveSettingsDebounced();
345}
346
347function onMemoryDepthInput() {
348 const value = $(this).val();
349 extension_settings.memory.depth = Number(value);
350 reinsertMemory();
351 saveSettingsDebounced();
352}
353
354function onMemoryRoleInput() {
355 const value = $(this).val();
356 extension_settings.memory.role = Number(value);
357 reinsertMemory();
358 saveSettingsDebounced();
359}
360
361function onMemorySummaryPromptRoleInput() {
362 const value = $(this).val();
363 extension_settings.memory.summaryPromptRole = Number(value);
364 saveSettingsDebounced();
365}
366
367function onMemoryPositionChange(e) {
368 const value = e.target.value;
369 extension_settings.memory.position = value;
370 reinsertMemory();
371 saveSettingsDebounced();
372}
373
374function onMemoryIncludeWIScanInput() {
375 const value = !!$(this).prop('checked');
376 extension_settings.memory.scan = value;
377 reinsertMemory();
378 saveSettingsDebounced();
379}
380
381function onMemoryPromptWordsForceInput() {
382 const value = $(this).val();
383 extension_settings.memory.promptForceWords = Number(value);
384 $('#memory_prompt_words_force_value').text(extension_settings.memory.promptForceWords);
385 saveSettingsDebounced();
386}
387
388function onOverrideResponseLengthInput() {
389 const value = $(this).val();
390 extension_settings.memory.overrideResponseLength = Number(value);
391 $('#memory_override_response_length_value').text(extension_settings.memory.overrideResponseLength);
392 saveSettingsDebounced();
393}
394
395function onMaxMessagesPerRequestInput() {
396 const value = $(this).val();
397 extension_settings.memory.maxMessagesPerRequest = Number(value);
398 $('#memory_max_messages_per_request_value').text(extension_settings.memory.maxMessagesPerRequest);
399 saveSettingsDebounced();
400}
401
402/**
403 * Get the latest memory summary from the chat.
404 * @param {ChatMessage[]} chat Chat messages
405 * @returns {string} Latest memory summary or empty string
406 */
407function getLatestMemoryFromChat(chat) {
408 if (!Array.isArray(chat) || !chat.length) {
409 return '';
410 }
411
412 const reversedChat = chat.slice().reverse();
413 reversedChat.shift();
414 for (let mes of reversedChat) {
415 if (mes.extra && mes.extra.memory) {
416 return mes.extra.memory;
417 }
418 }
419
420 return '';
421}
422
423/**
424 * Get the index of the latest memory summary from the chat.
425 * @param {ChatMessage[]} chat Chat messages
426 * @returns {number} Index of the latest memory summary or -1 if not found
427 */
428function getIndexOfLatestChatSummary(chat) {
429 if (!Array.isArray(chat) || !chat.length) {
430 return -1;
431 }
432
433 const reversedChat = chat.slice().reverse();
434 reversedChat.shift();
435 for (let mes of reversedChat) {
436 if (mes.extra && mes.extra.memory) {
437 return chat.indexOf(mes);
438 }
439 }
440
441 return -1;
442}
443
444/**
445 * Check if something is changed during the summarization process.
446 * @param {{ groupId: any; chatId: any; characterId: any; }} context
447 * @returns {boolean} True if the context has changed and the summary should be discarded
448 */
449function isContextChanged(context) {
450 const newContext = getContext();
451 if (newContext.groupId !== context.groupId
452 || newContext.chatId !== context.chatId
453 || (!newContext.groupId && (newContext.characterId !== context.characterId))) {
454 console.log('Context changed, summary discarded');
455 return true;
456 }
457
458 return false;
459}
460
461function onChatChanged() {
462 const context = getContext();
463 const latestMemory = getLatestMemoryFromChat(context.chat);
464 setMemoryContext(latestMemory, false);
465}
466
467async function onChatEvent() {
468 // Module not enabled
469 if (extension_settings.memory.source === summary_sources.extras && !modules.includes('summarize')) {
470 return;
471 }
472
473 // WebLLM is not supported
474 if (extension_settings.memory.source === summary_sources.webllm && !isWebLlmSupported()) {
475 return;
476 }
477
478 // Streaming in-progress
479 if (streamingProcessor && !streamingProcessor.isFinished) {
480 return;
481 }
482
483 // Currently summarizing or frozen state - skip
484 if (inApiCall || extension_settings.memory.memoryFrozen) {
485 return;
486 }
487
488 const context = getContext();
489 const chat = context.chat;
490 // Chat can't be empty.
491 if (chat.length === 0) return;
492
493 const lastMessage = chat[chat.length - 1];
494
495 // No new messages - do nothing
496 if ((lastMessageId === chat.length && getStringHash(lastMessage.mes) === lastMessageHash)) {
497 return;
498 }
499
500 // Messages has been deleted - rewrite the context with the latest available memory
501 if (chat.length < lastMessageId) {
502 const latestMemory = getLatestMemoryFromChat(chat);
503 setMemoryContext(latestMemory, false);
504 }
505
506 // Message has been edited / regenerated - delete the saved memory
507 if (chat.length
508 && lastMessage.extra
509 && lastMessage.extra.memory
510 && lastMessageId === chat.length
511 && getStringHash(lastMessage.mes) !== lastMessageHash) {
512 delete lastMessage.extra.memory;
513 }
514
515 summarizeChat(context)
516 .catch(console.error)
517 .finally(() => {
518 lastMessageId = context.chat?.length ?? null;
519 lastMessageHash = getStringHash((context.chat.length && context.chat[context.chat.length - 1].mes) ?? '');
520 });
521}
522
523/**
524 * Forces a summary generation for the current chat.
525 * @param {boolean} quiet If an informational toast should be displayed
526 * @returns {Promise<string>} Summarized text
527 */
528async function forceSummarizeChat(quiet) {
529 if (extension_settings.memory.source === summary_sources.extras) {
530 toastr.warning('Force summarization is not supported for Extras API');
531 return;
532 }
533
534 const context = getContext();
535 const skipWIAN = extension_settings.memory.SkipWIAN;
536
537 const toast = quiet ? jQuery() : toastr.info('Summarizing chat...', 'Please wait', { timeOut: 0, extendedTimeOut: 0 });
538 const value = extension_settings.memory.source === summary_sources.main
539 ? await summarizeChatMain(context, true, skipWIAN)
540 : await summarizeChatWebLLM(context, true);
541
542 toastr.clear(toast);
543
544 if (!value) {
545 toastr.warning('Failed to summarize chat');
546 return '';
547 }
548
549 return value;
550}
551
552/**
553 * Callback for the summarize command.
554 * @param {object} args Command arguments
555 * @param {string} text Text to summarize
556 */
557async function summarizeCallback(args, text) {
558 text = text.trim();
559
560 // Summarize the current chat if no text provided
561 if (!text) {
562 const quiet = isTrueBoolean(args.quiet);
563 return await forceSummarizeChat(quiet);
564 }
565
566 const source = args.source || extension_settings.memory.source;
567 const prompt = substituteParamsExtended((args.prompt || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords });
568 const useUserRole = getSummaryPromptRole() === extension_prompt_roles.USER;
569
570 try {
571 switch (source) {
572 case summary_sources.extras:
573 return await callExtrasSummarizeAPI(text);
574 case summary_sources.main: {
575 // When the instruction should be a USER message, combine it into the
576 // user content and clear the system prompt. Otherwise keep it as system.
577 const rawPrompt = useUserRole ? [prompt, text].filter(x => x).join('\n\n') : text;
578 const systemPrompt = useUserRole ? '' : prompt;
579 return removeReasoningFromString(await generateRaw({ prompt: rawPrompt, systemPrompt: systemPrompt, responseLength: extension_settings.memory.overrideResponseLength }));
580 }
581 case summary_sources.webllm: {
582 const promptRole = useUserRole ? 'user' : 'system';
583 const messages = [{ role: promptRole, content: prompt }, { role: 'user', content: text }].filter(m => m.content);
584 const params = extension_settings.memory.overrideResponseLength > 0 ? { max_tokens: extension_settings.memory.overrideResponseLength } : {};
585 return await generateWebLlmChatPrompt(messages, params);
586 }
587 default:
588 toastr.warning('Invalid summarization source specified');
589 return '';
590 }
591 } catch (error) {
592 toastr.error(String(error), 'Failed to summarize text');
593 console.log(error);
594 return '';
595 }
596}
597
598async function summarizeChat(context) {
599 const skipWIAN = extension_settings.memory.SkipWIAN;
600 switch (extension_settings.memory.source) {
601 case summary_sources.extras:
602 await summarizeChatExtras(context);
603 break;
604 case summary_sources.main:
605 await summarizeChatMain(context, false, skipWIAN);
606 break;
607 case summary_sources.webllm:
608 await summarizeChatWebLLM(context, false);
609 break;
610 default:
611 break;
612 }
613}
614
615/**
616 * Check if the chat should be summarized based on the current conditions.
617 * Return summary prompt if it should be summarized.
618 * @param {any} context ST context
619 * @param {boolean} force Summarize the chat regardless of the conditions
620 * @returns {Promise<string>} Summary prompt or empty string
621 */
622async function getSummaryPromptForNow(context, force) {
623 if (extension_settings.memory.promptInterval === 0 && !force) {
624 console.debug('Prompt interval is set to 0, skipping summarization');
625 return '';
626 }
627
628 try {
629 // Wait for group to finish generating
630 if (selected_group) {
631 await waitUntilCondition(() => is_group_generating === false, 1000, 10);
632 }
633 // Wait for the send button to be released
634 await waitUntilCondition(() => is_send_press === false, 30000, 100);
635 } catch {
636 console.debug('Timeout waiting for is_send_press');
637 return '';
638 }
639
640 if (!context.chat.length) {
641 console.debug('No messages in chat to summarize');
642 return '';
643 }
644
645 if (context.chat.length < extension_settings.memory.promptInterval && !force) {
646 console.debug(`Not enough messages in chat to summarize (chat: ${context.chat.length}, interval: ${extension_settings.memory.promptInterval})`);
647 return '';
648 }
649
650 let messagesSinceLastSummary = 0;
651 let wordsSinceLastSummary = 0;
652 let conditionSatisfied = false;
653 for (let i = context.chat.length - 1; i >= 0; i--) {
654 if (context.chat[i].extra && context.chat[i].extra.memory) {
655 break;
656 }
657 messagesSinceLastSummary++;
658 wordsSinceLastSummary += extractAllWords(context.chat[i].mes).length;
659 }
660
661 if (messagesSinceLastSummary >= extension_settings.memory.promptInterval) {
662 conditionSatisfied = true;
663 }
664
665 if (extension_settings.memory.promptForceWords && wordsSinceLastSummary >= extension_settings.memory.promptForceWords) {
666 conditionSatisfied = true;
667 }
668
669 if (!conditionSatisfied && !force) {
670 console.debug(`Summary conditions not satisfied (messages: ${messagesSinceLastSummary}, interval: ${extension_settings.memory.promptInterval}, words: ${wordsSinceLastSummary}, force words: ${extension_settings.memory.promptForceWords})`);
671 return '';
672 }
673
674 console.log('Summarizing chat, messages since last summary: ' + messagesSinceLastSummary, 'words since last summary: ' + wordsSinceLastSummary);
675 const prompt = substituteParamsExtended(extension_settings.memory.prompt, { words: extension_settings.memory.promptWords });
676
677 if (!prompt) {
678 console.debug('Summarization prompt is empty. Skipping summarization.');
679 return '';
680 }
681
682 return prompt;
683}
684
685async function summarizeChatWebLLM(context, force) {
686 if (!isWebLlmSupported()) {
687 return;
688 }
689
690 const prompt = await getSummaryPromptForNow(context, force);
691
692 if (!prompt) {
693 return;
694 }
695
696 const { rawPrompt, lastUsedIndex } = await getRawSummaryPrompt(context, prompt);
697
698 if (lastUsedIndex === null || lastUsedIndex === -1) {
699 if (force) {
700 toastr.info('To try again, remove the latest summary.', 'No messages found to summarize');
701 }
702
703 return null;
704 }
705
706 const promptRole = getSummaryPromptRole() === extension_prompt_roles.USER ? 'user' : 'system';
707 const messages = [
708 { role: promptRole, content: prompt },
709 { role: 'user', content: rawPrompt },
710 ];
711
712 const params = {};
713
714 if (extension_settings.memory.overrideResponseLength > 0) {
715 params.max_tokens = extension_settings.memory.overrideResponseLength;
716 }
717
718 try {
719 inApiCall = true;
720 const summary = await generateWebLlmChatPrompt(messages, params);
721
722 if (!summary) {
723 console.warn('Empty summary received');
724 return;
725 }
726
727 // something changed during summarization request
728 if (isContextChanged(context)) {
729 return;
730 }
731
732 setMemoryContext(summary, true, lastUsedIndex);
733 return summary;
734 } finally {
735 inApiCall = false;
736 }
737}
738
739// Warn at most once per session if a summary connection profile is selected but cannot be
740// applied because there is no active connection profile to restore afterward.
741let summaryProfileWarnedNoBaseProfile = false;
742
743/**
744 * Runs a callback with a specific Connection Manager profile temporarily active, then restores
745 * the previously active profile. This lets the summary be generated by the chosen LLM using the
746 * full summarization pipeline (generateQuietPrompt/generateRaw) under that connection, instead of
747 * a context-free Connection Manager request that some providers run with the wrong model.
748 *
749 * The switch is only performed when a real connection profile is currently active (so it can be
750 * reliably restored). When none is active — i.e. the user drives the API panel manually —
751 * switching to a profile could not be undone without clobbering those manual settings, so we leave
752 * the active model in place and warn once.
753 *
754 * @param {string} targetProfileId Profile to activate for the duration of the callback.
755 * @param {() => Promise<any>} callback Work to run while the target profile is active.
756 * @returns {Promise<any>} The callback's result.
757 */
758async function withConnectionProfile(targetProfileId, callback) {
759 const select = /** @type {HTMLSelectElement} */ (document.getElementById('connection_profiles'));
760 const connectionManager = extension_settings.connectionManager;
761 const currentProfileId = connectionManager?.selectedProfile;
762
763 const canSwitch = !!select
764 && !!connectionManager
765 && Array.isArray(connectionManager.profiles)
766 && connectionManager.profiles.some(p => p.id === targetProfileId)
767 && Array.from(select.options).some(o => o.value === targetProfileId)
768 && !!currentProfileId // a real profile is active, so it can be restored afterwards
769 && currentProfileId !== targetProfileId;
770
771 if (!canSwitch) {
772 // Selected but no base profile to restore from -> use the active model and warn once.
773 if (targetProfileId && !currentProfileId && !summaryProfileWarnedNoBaseProfile) {
774 summaryProfileWarnedNoBaseProfile = true;
775 toastr.info('The summary connection profile is only applied while a connection profile is active (so the original can be restored). Using the current model.', 'Summarize');
776 }
777 return await callback();
778 }
779
780 const switchToProfile = async (profileId) => {
781 const loaded = new Promise(resolve => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
782 const timeout = new Promise(resolve => setTimeout(resolve, 10000));
783 const index = Array.from(select.options).findIndex(o => o.value === profileId);
784 select.selectedIndex = index >= 0 ? index : 0;
785 select.dispatchEvent(new Event('change'));
786 // Wait for the profile's commands to finish applying (don't hang forever if the event never fires).
787 await Promise.race([loaded, timeout]);
788 // Applying a profile reconnects the API asynchronously; generating before it is
789 // re-established fails instantly, so wait for the connection to come back up
790 // (mirrors the built-in /profile command). rejectOnTimeout:false -> proceed anyway after the timeout.
791 await waitUntilCondition(() => online_status !== 'no_connection', 10000, 100, { rejectOnTimeout: false });
792 };
793
794 await switchToProfile(targetProfileId);
795 try {
796 return await callback();
797 } finally {
798 try {
799 await switchToProfile(currentProfileId);
800 } catch (err) {
801 console.error('Summarize: failed to restore the previous connection profile after summarization', err);
802 }
803 }
804}
805
806async function summarizeChatMain(context, force, skipWIAN) {
807 const prompt = await getSummaryPromptForNow(context, force);
808
809 if (!prompt) {
810 return;
811 }
812
813 console.log('sending summary prompt');
814
815 // Runs the configured summary builder (DEFAULT or RAW) against whatever connection is
816 // currently active. Returns { summary, index }, or null when there is nothing to summarize.
817 const runConfiguredSummary = async () => {
818 let summary = '';
819 let index = null;
820
821 if (prompt_builders.DEFAULT === extension_settings.memory.prompt_builder) {
822 // generateQuietPrompt always injects the instruction as a SYSTEM message
823 // (the QUIET_PROMPT extension prompt has no exposed role parameter).
824 // When a USER role is requested, assemble the chat transcript ourselves
825 // (same source as the raw builder) and deliver the instruction as USER
826 // content via generateRaw, so the model still receives the chat to
827 // summarize instead of just the bare instruction.
828 if (getSummaryPromptRole() === extension_prompt_roles.USER) {
829 const { rawPrompt } = await getRawSummaryPrompt(context, prompt);
830 /** @type {import('../../../script.js').GenerateRawParams} */
831 const params = {
832 prompt: [prompt, rawPrompt].filter(x => x).join('\n\n'),
833 systemPrompt: '',
834 responseLength: extension_settings.memory.overrideResponseLength,
835 };
836 summary = removeReasoningFromString(await generateRaw(params));
837 } else {
838 /** @type {import('../../../script.js').GenerateQuietPromptParams} */
839 const params = {
840 quietPrompt: prompt,
841 skipWIAN: skipWIAN,
842 responseLength: extension_settings.memory.overrideResponseLength,
843 };
844 summary = await generateQuietPrompt(params);
845 }
846 }
847
848 if ([prompt_builders.RAW_BLOCKING, prompt_builders.RAW_NON_BLOCKING].includes(extension_settings.memory.prompt_builder)) {
849 const lock = extension_settings.memory.prompt_builder === prompt_builders.RAW_BLOCKING;
850 try {
851 if (lock) {
852 deactivateSendButtons();
853 }
854
855 const { rawPrompt, lastUsedIndex } = await getRawSummaryPrompt(context, prompt);
856
857 if (lastUsedIndex === null || lastUsedIndex === -1) {
858 if (force) {
859 toastr.info('To try again, remove the latest summary.', 'No messages found to summarize');
860 }
861
862 return null;
863 }
864
865 // When the summary instruction should be a USER message, deliver it as
866 // user content and leave the system prompt empty. Otherwise keep the
867 // existing behavior of sending it as the system prompt.
868 const useUserRole = getSummaryPromptRole() === extension_prompt_roles.USER;
869 /** @type {import('../../../script.js').GenerateRawParams} */
870 const params = {
871 prompt: useUserRole ? [prompt, rawPrompt].filter(x => x).join('\n\n') : rawPrompt,
872 systemPrompt: useUserRole ? '' : prompt,
873 responseLength: extension_settings.memory.overrideResponseLength,
874 };
875 const rawSummary = await generateRaw(params);
876 summary = removeReasoningFromString(rawSummary);
877 index = lastUsedIndex;
878 } finally {
879 if (lock) {
880 activateSendButtons();
881 }
882 }
883 }
884
885 return { summary, index };
886 };
887
888 // A dedicated connection profile generates the summary with the chosen LLM by temporarily
889 // switching the active connection profile (so the full summarization pipeline runs under it),
890 // then restoring the previous profile. Falls back to the active model when no profile is set
891 // or no base profile is active to restore.
892 const summaryConnectionProfile = extension_settings.memory.summaryConnectionProfile;
893 let result;
894 try {
895 inApiCall = true;
896 result = summaryConnectionProfile
897 ? await withConnectionProfile(summaryConnectionProfile, runConfiguredSummary)
898 : await runConfiguredSummary();
899 } finally {
900 inApiCall = false;
901 }
902
903 if (result === null) {
904 return null;
905 }
906
907 const { summary, index } = result;
908
909 if (!summary) {
910 console.warn('Empty summary received');
911 return;
912 }
913
914 if (isContextChanged(context)) {
915 return;
916 }
917
918 setMemoryContext(summary, true, index);
919 return summary;
920}
921
922/**
923 * Get the raw summarization prompt from the chat context.
924 * @param {object} context ST context
925 * @param {string} prompt Summarization system prompt
926 * @returns {Promise<{rawPrompt: string, lastUsedIndex: number}>} Raw summarization prompt
927 */
928async function getRawSummaryPrompt(context, prompt) {
929 /**
930 * Get the memory string from the chat buffer.
931 * @param {boolean} includeSystem Include prompt into the memory string
932 * @returns {string} Memory string
933 */
934 function getMemoryString(includeSystem) {
935 const delimiter = '\n\n';
936 const stringBuilder = [];
937 const bufferString = chatBuffer.slice().join(delimiter);
938
939 if (includeSystem) {
940 stringBuilder.push(prompt);
941 }
942
943 if (latestSummary) {
944 stringBuilder.push(latestSummary);
945 }
946
947 stringBuilder.push(bufferString);
948
949 return stringBuilder.join(delimiter).trim();
950 }
951
952 const chat = context.chat.slice();
953 const latestSummary = getLatestMemoryFromChat(chat);
954 const latestSummaryIndex = getIndexOfLatestChatSummary(chat);
955 chat.pop(); // We always exclude the last message from the buffer
956 const chatBuffer = [];
957 const PADDING = 64;
958 const PROMPT_SIZE = await getSourceContextSize();
959 let latestUsedMessage = null;
960
961 for (let index = latestSummaryIndex + 1; index < chat.length; index++) {
962 const message = chat[index];
963
964 if (!message) {
965 break;
966 }
967
968 if (message.is_system || !message.mes) {
969 continue;
970 }
971
972 const entry = `${message.name}:\n${message.mes}`;
973 chatBuffer.push(entry);
974
975 const tokens = await countSourceTokens(getMemoryString(true), PADDING);
976
977 if (tokens > PROMPT_SIZE) {
978 chatBuffer.pop();
979 break;
980 }
981
982 latestUsedMessage = message;
983
984 if (extension_settings.memory.maxMessagesPerRequest > 0 && chatBuffer.length >= extension_settings.memory.maxMessagesPerRequest) {
985 break;
986 }
987 }
988
989 const lastUsedIndex = context.chat.indexOf(latestUsedMessage);
990 const rawPrompt = getMemoryString(false);
991 return { rawPrompt, lastUsedIndex };
992}
993
994async function summarizeChatExtras(context) {
995 function getMemoryString() {
996 return (longMemory + '\n\n' + memoryBuffer.slice().reverse().join('\n\n')).trim();
997 }
998
999 const chat = context.chat;
1000 const longMemory = getLatestMemoryFromChat(chat);
1001 const reversedChat = chat.slice().reverse();
1002 reversedChat.shift();
1003 const memoryBuffer = [];
1004 const CONTEXT_SIZE = await getSourceContextSize();
1005
1006 for (const message of reversedChat) {
1007 // we reached the point of latest memory
1008 if (longMemory && message.extra && message.extra.memory == longMemory) {
1009 break;
1010 }
1011
1012 // don't care about system
1013 if (message.is_system) {
1014 continue;
1015 }
1016
1017 // determine the sender's name
1018 const entry = `${message.name}:\n${message.mes}`;
1019 memoryBuffer.push(entry);
1020
1021 // check if token limit was reached
1022 const tokens = await countSourceTokens(getMemoryString());
1023 if (tokens >= CONTEXT_SIZE) {
1024 break;
1025 }
1026 }
1027
1028 const resultingString = getMemoryString();
1029 const resultingTokens = await countSourceTokens(resultingString);
1030
1031 if (!resultingString || resultingTokens < CONTEXT_SIZE) {
1032 console.debug('Not enough context to summarize');
1033 return;
1034 }
1035
1036 // perform the summarization API call
1037 try {
1038 inApiCall = true;
1039 const summary = await callExtrasSummarizeAPI(resultingString);
1040
1041 if (!summary) {
1042 console.warn('Empty summary received');
1043 return;
1044 }
1045
1046 if (isContextChanged(context)) {
1047 return;
1048 }
1049
1050 setMemoryContext(summary, true);
1051 } catch (error) {
1052 console.log(error);
1053 } finally {
1054 inApiCall = false;
1055 }
1056}
1057
1058/**
1059 * Call the Extras API to summarize the provided text.
1060 * @param {string} text Text to summarize
1061 * @returns {Promise<string>} Summarized text
1062 */
1063async function callExtrasSummarizeAPI(text) {
1064 if (!modules.includes('summarize')) {
1065 throw new Error('Summarize module is not enabled in Extras API');
1066 }
1067
1068 const url = new URL(getApiUrl());
1069 url.pathname = '/api/summarize';
1070
1071 const apiResult = await doExtrasFetch(url, {
1072 method: 'POST',
1073 headers: {
1074 'Content-Type': 'application/json',
1075 'Bypass-Tunnel-Reminder': 'bypass',
1076 },
1077 body: JSON.stringify({
1078 text: text,
1079 params: {},
1080 }),
1081 });
1082
1083 if (apiResult.ok) {
1084 const data = await apiResult.json();
1085 const summary = data.summary;
1086 return summary;
1087 }
1088
1089 throw new Error('Extras API call failed');
1090}
1091
1092function onMemoryRestoreClick() {
1093 const context = getContext();
1094 const content = $('#memory_contents').val();
1095 const reversedChat = context.chat.slice().reverse();
1096 reversedChat.shift();
1097
1098 for (let mes of reversedChat) {
1099 if (mes.extra && mes.extra.memory == content) {
1100 delete mes.extra.memory;
1101 break;
1102 }
1103 }
1104
1105 const newContent = getLatestMemoryFromChat(context.chat);
1106 setMemoryContext(newContent, false);
1107}
1108
1109function onMemoryContentInput() {
1110 const value = $(this).val();
1111 setMemoryContext(value, true);
1112}
1113
1114function onMemoryPromptBuilderInput(e) {
1115 const value = Number(e.target.value);
1116 extension_settings.memory.prompt_builder = value;
1117 saveSettingsDebounced();
1118}
1119
1120function reinsertMemory() {
1121 const existingValue = String($('#memory_contents').val());
1122 setMemoryContext(existingValue, false);
1123}
1124
1125/**
1126 * Set the summary value to the context and save it to the chat message extra.
1127 * @param {string} value Value of a summary
1128 * @param {boolean} saveToMessage Should the summary be saved to the chat message extra
1129 * @param {number|null} index Index of the chat message to save the summary to. If null, the pre-last message is used.
1130 */
1131function setMemoryContext(value, saveToMessage, index = null) {
1132 setExtensionPrompt(MODULE_NAME, formatMemoryValue(value), extension_settings.memory.position, extension_settings.memory.depth, extension_settings.memory.scan, extension_settings.memory.role);
1133 $('#memory_contents').val(value);
1134
1135 const summaryLog = value
1136 ? `Summary set to: ${value}. Position: ${extension_settings.memory.position}. Depth: ${extension_settings.memory.depth}. Role: ${extension_settings.memory.role}`
1137 : 'Summary has no content';
1138 console.debug(summaryLog);
1139
1140 const context = getContext();
1141 if (saveToMessage && context.chat.length) {
1142 const idx = index ?? context.chat.length - 2;
1143 const mes = context.chat[idx < 0 ? 0 : idx];
1144
1145 if (!mes.extra) {
1146 mes.extra = {};
1147 }
1148
1149 mes.extra.memory = value;
1150 saveChatDebounced();
1151 }
1152}
1153
1154function doPopout(e) {
1155 const target = e.target;
1156 //repurposes the zoomed avatar template to server as a floating div
1157 if ($('#summaryExtensionPopout').length === 0) {
1158 console.debug('did not see popout yet, creating');
1159 const originalHTMLClone = $(target).parent().parent().parent().find('.inline-drawer-content').html();
1160 const originalElement = $(target).parent().parent().parent().find('.inline-drawer-content');
1161 const template = $('#zoomed_avatar_template').html();
1162 const controlBarHtml = `<div class="panelControlBar flex-container">
1163 <div id="summaryExtensionPopoutheader" class="fa-solid fa-grip drag-grabber hoverglow"></div>
1164 <div id="summaryExtensionPopoutClose" class="fa-solid fa-circle-xmark hoverglow dragClose"></div>
1165 </div>`;
1166 const newElement = $(template);
1167 newElement.attr('id', 'summaryExtensionPopout')
1168 .css('opacity', 0)
1169 .removeClass('zoomed_avatar')
1170 .addClass('draggable')
1171 .empty();
1172 const prevSummaryBoxContents = $('#memory_contents').val().toString(); //copy summary box before emptying
1173 originalElement.empty();
1174 originalElement.html('<div class="flex-container alignitemscenter justifyCenter wide100p"><small>Currently popped out</small></div>');
1175 newElement.append(controlBarHtml).append(originalHTMLClone);
1176 $('#movingDivs').append(newElement);
1177 newElement.transition({ opacity: 1, duration: animation_duration, easing: animation_easing });
1178 $('#summaryExtensionDrawerContents').addClass('scrollableInnerFull');
1179 setMemoryContext(prevSummaryBoxContents, false); //paste prev summary box contents into popout box
1180 setupListeners();
1181 loadSettings();
1182 loadMovingUIState();
1183
1184 dragElement(newElement);
1185
1186 //setup listener for close button to restore extensions menu
1187 $('#summaryExtensionPopoutClose').off('click').on('click', function () {
1188 $('#summaryExtensionDrawerContents').removeClass('scrollableInnerFull');
1189 const summaryPopoutHTML = $('#summaryExtensionDrawerContents');
1190 $('#summaryExtensionPopout').fadeOut(animation_duration, () => {
1191 originalElement.empty();
1192 originalElement.append(summaryPopoutHTML);
1193 $('#summaryExtensionPopout').remove();
1194 });
1195 loadSettings();
1196 });
1197 } else {
1198 console.debug('saw existing popout, removing');
1199 $('#summaryExtensionPopout').fadeOut(animation_duration, () => { $('#summaryExtensionPopoutClose').trigger('click'); });
1200 }
1201}
1202
1203function setupListeners() {
1204 //setup shared listeners for popout and regular ext menu
1205 $('#memory_restore').off('click').on('click', onMemoryRestoreClick);
1206 $('#memory_contents').off('input').on('input', onMemoryContentInput);
1207 $('#memory_frozen').off('input').on('input', onMemoryFrozenInput);
1208 $('#memory_skipWIAN').off('input').on('input', onMemorySkipWIANInput);
1209 $('#summary_source').off('change').on('change', onSummarySourceChange);
1210 $('#memory_prompt_words').off('input').on('input', onMemoryPromptWordsInput);
1211 $('#memory_prompt_interval').off('input').on('input', onMemoryPromptIntervalInput);
1212 $('#memory_prompt').off('input').on('input', onMemoryPromptInput);
1213 $('#memory_force_summarize').off('click').on('click', () => forceSummarizeChat(false));
1214 $('#memory_template').off('input').on('input', onMemoryTemplateInput);
1215 $('#memory_depth').off('input').on('input', onMemoryDepthInput);
1216 $('#memory_role').off('input').on('input', onMemoryRoleInput);
1217 $('#memory_summary_prompt_role').off('input').on('input', onMemorySummaryPromptRoleInput);
1218 $('input[name="memory_position"]').off('change').on('change', onMemoryPositionChange);
1219 $('#memory_prompt_words_force').off('input').on('input', onMemoryPromptWordsForceInput);
1220 $('#memory_prompt_builder_default').off('input').on('input', onMemoryPromptBuilderInput);
1221 $('#memory_prompt_builder_raw_blocking').off('input').on('input', onMemoryPromptBuilderInput);
1222 $('#memory_prompt_builder_raw_non_blocking').off('input').on('input', onMemoryPromptBuilderInput);
1223 $('#memory_prompt_restore').off('click').on('click', onMemoryPromptRestoreClick);
1224 $('#memory_prompt_interval_auto').off('click').on('click', onPromptIntervalAutoClick);
1225 $('#memory_prompt_words_auto').off('click').on('click', onPromptForceWordsAutoClick);
1226 $('#memory_override_response_length').off('input').on('input', onOverrideResponseLengthInput);
1227 $('#memory_max_messages_per_request').off('input').on('input', onMaxMessagesPerRequestInput);
1228 $('#memory_include_wi_scan').off('input').on('input', onMemoryIncludeWIScanInput);
1229 $('#summarySettingsBlockToggle').off('click').on('click', function () {
1230 $('#summarySettingsBlock').slideToggle(200, 'swing');
1231 });
1232}
1233
1234export async function init() {
1235 async function addExtensionControls() {
1236 const settingsHtml = await renderExtensionTemplateAsync('memory', 'settings', { defaultSettings });
1237 $('#summarize_container').append(settingsHtml);
1238 setupListeners();
1239 $('#summaryExtensionPopoutButton').off('click').on('click', function (e) {
1240 doPopout(e);
1241 e.stopPropagation();
1242 });
1243 }
1244
1245 await addExtensionControls();
1246 loadSettings();
1247 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
1248 eventSource.makeLast(event_types.CHARACTER_MESSAGE_RENDERED, onChatEvent);
1249 for (const event of [event_types.MESSAGE_DELETED, event_types.MESSAGE_UPDATED, event_types.MESSAGE_SWIPED]) {
1250 eventSource.on(event, onChatEvent);
1251 }
1252 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1253 name: 'summarize',
1254 callback: summarizeCallback,
1255 namedArgumentList: [
1256 new SlashCommandNamedArgument('source', 'API to use for summarization', [ARGUMENT_TYPE.STRING], false, false, '', Object.values(summary_sources)),
1257 SlashCommandNamedArgument.fromProps({
1258 name: 'prompt',
1259 description: 'prompt to use for summarization',
1260 typeList: [ARGUMENT_TYPE.STRING],
1261 defaultValue: '',
1262 }),
1263 SlashCommandNamedArgument.fromProps({
1264 name: 'quiet',
1265 description: 'suppress the toast message when summarizing the chat',
1266 typeList: [ARGUMENT_TYPE.BOOLEAN],
1267 defaultValue: 'false',
1268 enumList: commonEnumProviders.boolean('trueFalse')(),
1269 }),
1270 ],
1271 unnamedArgumentList: [
1272 new SlashCommandArgument('text to summarize', [ARGUMENT_TYPE.STRING], false, false, ''),
1273 ],
1274 helpString: 'Summarizes the given text. If no text is provided, the current chat will be summarized. Can specify the source and the prompt to use.',
1275 returns: ARGUMENT_TYPE.STRING,
1276 }));
1277
1278 const summaryMacroHandler = () => {
1279 // Checking content of the UI summary box first
1280 const uiSummary = $('#memory_contents').val().toString();
1281 if (uiSummary.trim().length > 0) {
1282 return uiSummary;
1283 }
1284 // Fallback to scanning the chat for the latest summary if the UI summary box is empty
1285 return getLatestMemoryFromChat(getContext().chat);
1286 };
1287 if (power_user.experimental_macro_engine) {
1288 macros.register('summary', {
1289 category: MacroCategory.CHAT,
1290 description: 'Returns the latest memory/summary from the current chat.',
1291 handler: () => summaryMacroHandler(),
1292 });
1293 } else {
1294 // TODO: Remove this when the experimental macro engine is replacing the old macro engine
1295 MacrosParser.registerMacro('summary',
1296 () => summaryMacroHandler(),
1297 'Returns the latest memory/summary from the current chat.');
1298 }
1299}
public/scripts/extensions/memory/manifest.json+0 -16
@@ -1,16 +0,0 @@
1{
2 "display_name": "Summarize",
3 "loading_order": 9,
4 "requires": [],
5 "optional": [
6 "summarize"
7 ],
8 "js": "index.js",
9 "css": "style.css",
10 "author": "Cohee#1207",
11 "version": "1.0.0",
12 "homePage": "https://github.com/SillyTavern/SillyTavern",
13 "hooks": {
14 "activate": "init"
15 }
16}
public/scripts/extensions/memory/settings.html+0 -157
@@ -1,157 +0,0 @@
1<div id="memory_settings">
2 <div class="inline-drawer">
3 <div class="inline-drawer-toggle inline-drawer-header">
4 <div class="flex-container alignitemscenter margin0">
5 <b data-i18n="ext_sum_title">Summarize</b>
6 <i id="summaryExtensionPopoutButton" class="fa-solid fa-window-restore menu_button margin0"></i>
7 </div>
8 <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
9 </div>
10 <div class="inline-drawer-content">
11 <div id="summaryExtensionDrawerContents">
12 <label for="summary_source" data-i18n="ext_sum_with">Summarize with:</label>
13 <select id="summary_source" class="text_pole">
14 <option value="main" data-i18n="ext_sum_main_api">Main API</option>
15 <option value="extras">Extras API (deprecated)</option>
16 <option value="webllm" data-i18n="ext_sum_webllm">WebLLM Extension</option>
17 </select><br>
18
19 <div class="flex-container justifyspacebetween alignitemscenter">
20 <span data-i18n="ext_sum_current_summary">Current summary:</span>
21 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="memory_contents" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
22 <span class="flex1">&nbsp;</span>
23 <div id="memory_restore" class="menu_button margin0" data-i18n="[title]ext_sum_restore_tip" title="Restore a previous summary; use repeatedly to clear summarization state for this chat.">
24 <small data-i18n="ext_sum_restore_previous">Restore Previous</small>
25 </div>
26 </div>
27
28 <textarea id="memory_contents" class="text_pole textarea_compact" rows="6" data-i18n="[placeholder]ext_sum_memory_placeholder" placeholder="Summary will be generated here..."></textarea>
29 <div class="memory_contents_controls">
30 <div id="memory_force_summarize" data-summary-source="main,webllm" class="menu_button menu_button_icon" title="Trigger a summary update right now." data-i18n="[title]ext_sum_force_tip">
31 <i class="fa-solid fa-database"></i>
32 <span data-i18n="ext_sum_force_text">Summarize now</span>
33 </div>
34 <label for="memory_frozen" title="Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API)." data-i18n="[title]Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API)."><input id="memory_frozen" type="checkbox" /><span data-i18n="ext_sum_pause">Pause</span></label>
35 <label data-summary-source="main" for="memory_skipWIAN" title="Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN." data-i18n="[title]Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.">
36 <input id="memory_skipWIAN" type="checkbox" />
37 <span data-i18n="ext_sum_no_wi_an">No WI/AN</span>
38 </label>
39 </div>
40 <div class="memory_contents_controls">
41 <div id="summarySettingsBlockToggle" class="menu_button menu_button_icon" data-i18n="[title]ext_sum_settings_tip" title="Edit summarization prompt, insertion position, etc.">
42 <i class="fa-solid fa-cog"></i>
43 <span data-i18n="ext_sum_settings">Summary Settings</span>
44 </div>
45 </div>
46 <div id="summarySettingsBlock" style="display:none;">
47 <div data-summary-source="main">
48 <label data-i18n="ext_sum_prompt_builder">
49 Prompt builder
50 </label>
51 <label class="checkbox_label" for="memory_prompt_builder_raw_blocking" data-i18n="[title]ext_sum_prompt_builder_1_desc" title="Extension will build its own prompt using messages that were not summarized yet. Blocks the chat until the summary is generated.">
52 <input id="memory_prompt_builder_raw_blocking" type="radio" name="memory_prompt_builder" value="1" />
53 <span data-i18n="ext_sum_prompt_builder_1">Raw, blocking</span>
54 </label>
55 <label class="checkbox_label" for="memory_prompt_builder_raw_non_blocking" data-i18n="[title]ext_sum_prompt_builder_2_desc" title="Extension will build its own prompt using messages that were not summarized yet. Does not block the chat while the summary is being generated. Not all backends support this mode.">
56 <input id="memory_prompt_builder_raw_non_blocking" type="radio" name="memory_prompt_builder" value="2" />
57 <span data-i18n="ext_sum_prompt_builder_2">Raw, non-blocking</span>
58 </label>
59 <label class="checkbox_label" id="memory_prompt_builder_default" data-i18n="[title]ext_sum_prompt_builder_3_desc" title="Extension will use the regular main prompt builder and add the summary request to it as the last system message.">
60 <input id="memory_prompt_builder_default" type="radio" name="memory_prompt_builder" value="0" />
61 <span data-i18n="ext_sum_prompt_builder_3">Classic, blocking</span>
62 </label>
63 </div>
64 <div data-summary-source="main,webllm">
65 <label for="memory_prompt" class="title_restorable">
66 <span data-i18n="Summary Prompt">Summary Prompt</span>
67 <div id="memory_prompt_restore" data-i18n="[title]ext_sum_restore_default_prompt_tip" title="Restore default prompt" class="right_menu_button">
68 <div class="fa-solid fa-clock-rotate-left"></div>
69 </div>
70 </label>
71 <textarea id="memory_prompt" class="text_pole textarea_compact" rows="6" data-i18n="[placeholder]ext_sum_prompt_placeholder" placeholder="This prompt will be sent to AI to request the summary generation. &lcub;&lcub;words&rcub;&rcub; will resolve to the 'Number of words' parameter."></textarea>
72 <label for="memory_summary_prompt_role" data-i18n="ext_sum_prompt_role">Summary Prompt Role</label>
73 <select id="memory_summary_prompt_role" class="text_pole">
74 <option value="0" data-i18n="System">System</option>
75 <option value="1" data-i18n="User">User</option>
76 </select>
77 <small data-i18n="ext_sum_prompt_role_desc">The role used to send the summary request instruction to the model.</small>
78 <label for="memory_summary_connection_profile" data-i18n="ext_sum_connection_profile">Summary Connection Profile</label>
79 <select id="memory_summary_connection_profile" class="text_pole"></select>
80 <small data-i18n="ext_sum_connection_profile_desc">Summarize with a specific connection profile instead of the active model. Leave empty to use the active model.</small>
81 <label for="memory_prompt_words"><span data-i18n="ext_sum_target_length_1">Target summary length</span> <span data-i18n="ext_sum_target_length_2">(</span><span id="memory_prompt_words_value"></span><span data-i18n="ext_sum_target_length_3"> words)</span></label>
82 <input id="memory_prompt_words" type="range" value="{{defaultSettings.promptWords}}" min="{{defaultSettings.promptMinWords}}" max="{{defaultSettings.promptMaxWords}}" step="{{defaultSettings.promptWordsStep}}" />
83 <label for="memory_override_response_length">
84 <span data-i18n="ext_sum_api_response_length_1">API response length</span> <span data-i18n="ext_sum_api_response_length_2">(</span><span id="memory_override_response_length_value"></span><span data-i18n="ext_sum_api_response_length_3"> tokens)</span>
85 <small class="memory_disabled_hint" data-i18n="ext_sum_0_default">0 = default</small>
86 </label>
87 <input id="memory_override_response_length" type="range" value="{{defaultSettings.overrideResponseLength}}" min="{{defaultSettings.overrideResponseLengthMin}}" max="{{defaultSettings.overrideResponseLengthMax}}" step="{{defaultSettings.overrideResponseLengthStep}}" />
88 <label for="memory_max_messages_per_request">
89 <span data-i18n="ext_sum_raw_max_msg">[Raw/WebLLM] Max messages per request</span> (<span id="memory_max_messages_per_request_value"></span>)
90 <small class="memory_disabled_hint" data-i18n="ext_sum_0_unlimited">0 = unlimited</small>
91 </label>
92 <input id="memory_max_messages_per_request" type="range" value="{{defaultSettings.maxMessagesPerRequest}}" min="{{defaultSettings.maxMessagesPerRequestMin}}" max="{{defaultSettings.maxMessagesPerRequestMax}}" step="{{defaultSettings.maxMessagesPerRequestStep}}" />
93 <h4 data-i18n="Update frequency" class="textAlignCenter">
94 Update frequency
95 </h4>
96 <label for="memory_prompt_interval" class="title_restorable">
97 <span>
98 <span data-i18n="ext_sum_update_every_messages_1">Update every</span> <span id="memory_prompt_interval_value"></span><span data-i18n="ext_sum_update_every_messages_2"> messages</span>
99 <small class="memory_disabled_hint" data-i18n="ext_sum_0_disable">0 = disable</small>
100 </span>
101 <div id="memory_prompt_interval_auto" data-i18n="[title]ext_sum_auto_adjust_desc" title="Try to automatically adjust the interval based on the chat metrics." class="right_menu_button">
102 <div class="fa-solid fa-wand-magic-sparkles"></div>
103 </div>
104 </label>
105 <input id="memory_prompt_interval" type="range" value="{{defaultSettings.promptInterval}}" min="{{defaultSettings.promptMinInterval}}" max="{{defaultSettings.promptMaxInterval}}" step="{{defaultSettings.promptIntervalStep}}" />
106 <label for="memory_prompt_words_force" class="title_restorable">
107 <span>
108 <span data-i18n="ext_sum_update_every_words_1">Update every</span> <span id="memory_prompt_words_force_value"></span><span data-i18n="ext_sum_update_every_words_2"> words</span>
109 <small class="memory_disabled_hint" data-i18n="ext_sum_0_disable">0 = disable</small>
110 </span>
111 <div id="memory_prompt_words_auto" data-i18n="[title]ext_sum_auto_adjust_desc" title="Try to automatically adjust the interval based on the chat metrics." class="right_menu_button">
112 <div class="fa-solid fa-wand-magic-sparkles"></div>
113 </div>
114 </label>
115 <input id="memory_prompt_words_force" type="range" value="{{defaultSettings.promptForceWords}}" min="{{defaultSettings.promptMinForceWords}}" max="{{defaultSettings.promptMaxForceWords}}" step="{{defaultSettings.promptForceWordsStep}}" />
116 <small data-i18n="ext_sum_both_sliders">If both sliders are non-zero, then both will trigger summary updates at their respective intervals.</small>
117 <hr>
118 </div>
119 <div class="memory_template">
120 <label for="memory_template" data-i18n="ext_sum_injection_template">Injection Template</label>
121 <textarea id="memory_template" class="text_pole textarea_compact" rows="2" data-i18n="[placeholder]ext_sum_memory_template_placeholder" placeholder="&lcub;&lcub;summary&rcub;&rcub; will resolve to the current summary contents."></textarea>
122 </div>
123 <label for="memory_position" data-i18n="ext_sum_injection_position">Injection Position</label>
124 <label class="checkbox_label" for="memory_include_wi_scan" data-i18n="[title]ext_sum_include_wi_scan_desc" title="Include the latest summary in the WI scan.">
125 <input id="memory_include_wi_scan" type="checkbox" />
126 <span data-i18n="ext_sum_include_wi_scan">Include in World Info Scanning</span>
127 </label>
128 <div class="radio_group">
129 <label>
130 <input type="radio" name="memory_position" value="-1" />
131 <span data-i18n="None (not injected)">None (not injected)</span>
132 <i class="fa-solid fa-info-circle" title="The summary will not be injected into the prompt. You can still access it via the &lcub;&lcub;summary&rcub;&rcub; macro." data-i18n="[title]ext_sum_injection_position_none"></i>
133 </label>
134 <label>
135 <input type="radio" name="memory_position" value="2" />
136 <span data-i18n="Before Main Prompt / Story String">Before Main Prompt / Story String</span>
137 </label>
138 <label>
139 <input type="radio" name="memory_position" value="0" />
140 <span data-i18n="After Main Prompt / Story String">After Main Prompt / Story String</span>
141 </label>
142 <label class="flex-container alignItemsCenter" title="How many messages before the current end of the chat." data-i18n="[title]How many messages before the current end of the chat.">
143 <input type="radio" name="memory_position" value="1" />
144 <span data-i18n="In-chat @ Depth">In-chat @ Depth</span> <input id="memory_depth" class="text_pole widthUnset" type="number" min="0" max="9999" />
145 <span data-i18n="as">as</span>
146 <select id="memory_role" class="text_pole widthNatural">
147 <option value="0" data-i18n="System">System</option>
148 <option value="1" data-i18n="User">User</option>
149 <option value="2" data-i18n="Assistant">Assistant</option>
150 </select>
151 </label>
152 </div>
153 </div>
154 </div>
155 </div>
156 </div>
157</div>
public/scripts/extensions/memory/style.css+0 -47
@@ -1,47 +0,0 @@
1#memory_settings {
2 display: flex;
3 flex-direction: column;
4}
5
6#memory_contents {
7 field-sizing: content;
8 max-height: 50dvh;
9}
10
11#memory_restore {
12 width: max-content;
13}
14
15label[for="memory_frozen"],
16label[for="memory_skipWIAN"] {
17 display: flex;
18 align-items: center;
19 margin: 0 !important;
20}
21
22label[for="memory_frozen"] input {
23 margin-right: 10px;
24}
25
26.memory_contents_controls {
27 display: flex;
28 flex-direction: row;
29 align-items: center;
30 justify-content: space-between;
31}
32
33.memory_disabled_hint {
34 margin-left: 2px;
35}
36
37#summarySettingsBlock {
38 display: flex;
39 flex-direction: column;
40 row-gap: 5px;
41}
42
43#summaryExtensionPopout {
44 display: flex;
45 flex-direction: column;
46 padding-top: 25px;
47}
public/scripts/extensions/stable-diffusion/button.html+0 -4
@@ -1,4 +0,0 @@
1<div id="sd_gen" class="list-group-item flex-container flexGap5">
2 <div class="fa-solid fa-paintbrush extensionsMenuExtensionButton" title="Trigger Stable Diffusion" data-i18n="[title]Trigger Stable Diffusion"></div>
3 <span data-i18n="Generate Image">Generate Image</span>
4</div>
public/scripts/extensions/stable-diffusion/comfyWorkflowEditor.html+0 -44
@@ -1,44 +0,0 @@
1<div id="sd_comfy_workflow_editor_template">
2 <div class="sd_comfy_workflow_editor">
3 <h3><strong>ComfyUI Workflow Editor: <span id="sd_comfy_workflow_editor_name"></span></strong></h3>
4 <div class="sd_comfy_workflow_editor_content">
5 <div class="flex-container flexFlowColumn sd_comfy_workflow_editor_workflow_container">
6 <label for="sd_comfy_workflow_editor_workflow">Workflow (JSON)</label>
7 <textarea id="sd_comfy_workflow_editor_workflow" class="text_pole wide100p textarea_compact flex1" placeholder="Insert your ComfyUI workflow here by copying the JSON data obtained via the 'Save (API Format)' option. This option becomes available after enabling 'Dev Mode' in the settings. Remember to replace specific values within your workflow with placeholders as required for your use case."></textarea>
8 </div>
9 <div class="sd_comfy_workflow_editor_placeholder_container">
10 <div>Placeholders</div>
11 <ul class="sd_comfy_workflow_editor_placeholder_list">
12 <li data-placeholder="prompt" class="sd_comfy_workflow_editor_not_found">"%prompt%"</li>
13 <li data-placeholder="negative_prompt" class="sd_comfy_workflow_editor_not_found">"%negative_prompt%"</li>
14 <li data-placeholder="model" class="sd_comfy_workflow_editor_not_found">"%model%"</li>
15 <li data-placeholder="vae" class="sd_comfy_workflow_editor_not_found">"%vae%"</li>
16 <li data-placeholder="lora" class="sd_comfy_workflow_editor_not_found">"%lora%"</li>
17 <li data-placeholder="lora_strength" class="sd_comfy_workflow_editor_not_found">"%lora_strength%"</li>
18 <li data-placeholder="sampler" class="sd_comfy_workflow_editor_not_found">"%sampler%"</li>
19 <li data-placeholder="scheduler" class="sd_comfy_workflow_editor_not_found">"%scheduler%"</li>
20 <li data-placeholder="steps" class="sd_comfy_workflow_editor_not_found">"%steps%"</li>
21 <li data-placeholder="scale" class="sd_comfy_workflow_editor_not_found">"%scale%"</li>
22 <li data-placeholder="denoise" class="sd_comfy_workflow_editor_not_found">"%denoise%"</li>
23 <li data-placeholder="clip_skip" class="sd_comfy_workflow_editor_not_found">"%clip_skip%"</li>
24 <li data-placeholder="width" class="sd_comfy_workflow_editor_not_found">"%width%"</li>
25 <li data-placeholder="height" class="sd_comfy_workflow_editor_not_found">"%height%"</li>
26 <li data-placeholder="user_avatar" class="sd_comfy_workflow_editor_not_found">"%user_avatar%"</li>
27 <li data-placeholder="char_avatar" class="sd_comfy_workflow_editor_not_found">"%char_avatar%"</li>
28 <li data-placeholder="reference_image" class="sd_comfy_workflow_editor_not_found" title="Best-fitting image from the Reference Image Library, selected by the image-prompt LLM">"%reference_image%"</li>
29 <li><hr></li>
30 <li data-placeholder="seed" class="sd_comfy_workflow_editor_not_found">
31 "%seed%"
32 <a href="javascript:;" class="notes-link"><span class="note-link-span" title="Will generate a new random seed in SillyTavern that is then used in the ComfyUI workflow.">?</span></a>
33 </li>
34 </ul>
35 <div>Custom</div>
36 <div class="sd_comfy_workflow_editor_placeholder_actions">
37 <span id="sd_comfy_workflow_editor_placeholder_add" title="Add custom placeholder">+</span>
38 </div>
39 <ul class="sd_comfy_workflow_editor_placeholder_list" id="sd_comfy_workflow_editor_placeholder_list_custom">
40 </ul>
41 </div>
42 </div>
43 </div>
44</div>
public/scripts/extensions/stable-diffusion/dropdown.html+0 -12
@@ -1,12 +0,0 @@
1<div id="sd_dropdown">
2 <ul class="list-group">
3 <span data-i18n="Send me a picture of:">Send me a picture of:</span>
4 <li class="list-group-item" id="sd_you" data-value="you" data-i18n="sd_Yourself">Yourself</li>
5 <li class="list-group-item" id="sd_face" data-value="face" data-i18n="sd_Your_Face">Your Face</li>
6 <li class="list-group-item" id="sd_me" data-value="me" data-i18n="sd_Me">Me</li>
7 <li class="list-group-item" id="sd_world" data-value="world" data-i18n="sd_The_Whole_Story">The Whole Story</li>
8 <li class="list-group-item" id="sd_last" data-value="last" data-i18n="sd_The_Last_Message">The Last Message</li>
9 <li class="list-group-item" id="sd_raw_last" data-value="raw_last" data-i18n="sd_Raw_Last_Message">Raw Last Message</li>
10 <li class="list-group-item" id="sd_background" data-value="background" data-i18n="sd_Background">Background</li>
11 </ul>
12</div>
public/scripts/extensions/stable-diffusion/index.js+0 -6642
@@ -1,7686 +0,0 @@
1import { Popper } from '../../../lib.js';
2import {
3 animation_duration,
4 appendMediaToMessage,
5 event_types,
6 eventSource,
7 formatCharacterAvatar,
8 generateQuietPrompt,
9 getCharacterAvatar,
10 getCurrentChatId,
11 getRequestHeaders,
12 getUserAvatar,
13 online_status,
14 saveSettingsDebounced,
15 substituteParams,
16 substituteParamsExtended,
17 systemUserName,
18 this_chid,
19 user_avatar,
20} from '../../../script.js';
21import {
22 doExtrasFetch,
23 extension_settings,
24 getApiUrl,
25 getContext,
26 modules,
27 renderExtensionTemplateAsync,
28 writeExtensionField,
29} from '../../extensions.js';
30import { selected_group } from '../../group-chats.js';
31import {
32 clamp,
33 debounce,
34 deepMerge,
35 delay,
36 getBase64Async,
37 getCharaFilename,
38 initScrollHeight,
39 isFalseBoolean,
40 isTrueBoolean,
41 resetScrollHeight,
42 saveBase64AsFile,
43 stringFormat,
44 waitUntilCondition,
45} from '../../utils.js';
46import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js';
47import { SECRET_KEYS, secret_state } from '../../secrets.js';
48import { getNovelAnlas, getNovelUnlimitedImageGeneration, loadNovelSubscriptionData } from '../../nai-settings.js';
49import { ConnectionManagerRequestService, getMultimodalCaption } from '../shared.js';
50import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
51import { SlashCommand } from '../../slash-commands/SlashCommand.js';
52import {
53 ARGUMENT_TYPE,
54 SlashCommandArgument,
55 SlashCommandNamedArgument,
56} from '../../slash-commands/SlashCommandArgument.js';
57import { debounce_timeout, IMAGE_OVERSWIPE, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION, VIDEO_EXTENSIONS } from '../../constants.js';
58import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
59import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
60import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
61import { ToolManager } from '../../tool-calling.js';
62import { macros, MacroCategory } from '../../macros/macro-system.js';
63import { t, translate } from '../../i18n.js';
64import { oai_settings } from '../../openai.js';
65import { power_user } from '/scripts/power-user.js';
66import { MacrosParser } from '/scripts/macros.js';
67import { ActionLoaderHandle, loader } from '/scripts/action-loader.js';
68
69export { MODULE_NAME };
70
71const MODULE_NAME = 'sd';
72// This is a 1x1 transparent PNG
73const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
74
75const sources = {
76 extras: 'extras',
77 horde: 'horde',
78 auto: 'auto',
79 sdcpp: 'sdcpp',
80 novel: 'novel',
81 vlad: 'vlad',
82 openai: 'openai',
83 aimlapi: 'aimlapi',
84 comfy: 'comfy',
85 togetherai: 'togetherai',
86 drawthings: 'drawthings',
87 pollinations: 'pollinations',
88 stability: 'stability',
89 huggingface: 'huggingface',
90 chutes: 'chutes',
91 electronhub: 'electronhub',
92 nanogpt: 'nanogpt',
93 bfl: 'bfl',
94 falai: 'falai',
95 xai: 'xai',
96 google: 'google',
97 zai: 'zai',
98 openrouter: 'openrouter',
99 workersai: 'workersai',
100};
101const comfyTypes = {
102 standard: 'standard',
103 runpod_serverless: 'runpod_serverless',
104};
105
106const initiators = {
107 command: 'command',
108 action: 'action',
109 interactive: 'interactive',
110 wand: 'wand',
111 swipe: 'swipe',
112 tool: 'tool',
113};
114
115const generationMode = {
116 TOOL: -2,
117 MESSAGE: -1,
118 CHARACTER: 0,
119 USER: 1,
120 SCENARIO: 2,
121 RAW_LAST: 3,
122 NOW: 4,
123 FACE: 5,
124 FREE: 6,
125 BACKGROUND: 7,
126 CHARACTER_MULTIMODAL: 8,
127 USER_MULTIMODAL: 9,
128 FACE_MULTIMODAL: 10,
129 FREE_EXTENDED: 11,
130 CUSTOM: 12,
131};
132
133const multimodalMap = {
134 [generationMode.CHARACTER]: generationMode.CHARACTER_MULTIMODAL,
135 [generationMode.USER]: generationMode.USER_MULTIMODAL,
136 [generationMode.FACE]: generationMode.FACE_MULTIMODAL,
137};
138
139const modeLabels = {
140 [generationMode.TOOL]: 'Function Tool Prompt Description',
141 [generationMode.MESSAGE]: 'Chat Message Template',
142 [generationMode.CHARACTER]: 'Character ("Yourself")',
143 [generationMode.FACE]: 'Portrait ("Your Face")',
144 [generationMode.USER]: 'User ("Me")',
145 [generationMode.SCENARIO]: 'Scenario ("The Whole Story")',
146 [generationMode.NOW]: 'Last Message',
147 [generationMode.RAW_LAST]: 'Raw Last Message',
148 [generationMode.BACKGROUND]: 'Background',
149 [generationMode.CHARACTER_MULTIMODAL]: 'Character (Multimodal Mode)',
150 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',
151 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',
152 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',
153 [generationMode.CUSTOM]: 'Custom',
154};
155
156const triggerWords = {
157 [generationMode.CHARACTER]: ['you'],
158 [generationMode.USER]: ['me'],
159 [generationMode.SCENARIO]: ['scene'],
160 [generationMode.RAW_LAST]: ['raw_last'],
161 [generationMode.NOW]: ['last'],
162 [generationMode.FACE]: ['face'],
163 [generationMode.BACKGROUND]: ['background'],
164};
165
166const messageTrigger = {
167 activationRegex: /\b(send|mail|imagine|generate|make|create|draw|paint|render|show)\b.{0,10}\b(pic|picture|image|drawing|painting|photo|photograph)\b(?:\s+of)?(?:\s+(?:a|an|the|this|that|those|your)?\s+)?(.+)/i,
168 specialCases: {
169 [generationMode.CHARACTER]: ['you', 'yourself'],
170 [generationMode.USER]: ['me', 'myself'],
171 [generationMode.SCENARIO]: ['story', 'scenario', 'whole story'],
172 [generationMode.NOW]: ['last message'],
173 [generationMode.FACE]: ['face', 'portrait', 'selfie'],
174 [generationMode.BACKGROUND]: ['background', 'scene background', 'scene', 'scenery', 'surroundings', 'environment'],
175 },
176};
177
178const promptTemplates = {
179 // Not really a prompt template, rather an outcome message template and function tool prompt
180 [generationMode.MESSAGE]: '[{{char}} sends a picture that contains: {{prompt}}].',
181 [generationMode.TOOL]: [
182 'The text prompt used to generate the image.',
183 'Must represent an exhaustive description of the desired image that will allow an artist or a photographer to perfectly recreate it.',
184 ].join(' '),
185 [generationMode.CHARACTER]: 'In the next response I want you to provide only a detailed comma-delimited list of keywords and phrases which describe {{char}}. The list must include all of the following items in this order: name, species and race, gender, age, clothing, occupation, physical features and appearances. Do not include descriptions of non-visual qualities such as personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'full body portrait,\'',
186 //face-specific prompt
187 [generationMode.FACE]: 'In the next response I want you to provide only a detailed comma-delimited list of keywords and phrases which describe {{char}}. The list must include all of the following items in this order: name, species and race, gender, age, facial features and expressions, occupation, hair and hair accessories (if any), what they are wearing on their upper body (if anything). Do not describe anything below their neck. Do not include descriptions of non-visual qualities such as personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'close up facial portrait,\'',
188 //prompt for only the last message
189 [generationMode.USER]: 'Ignore previous instructions and provide a detailed description of {{user}}\'s physical appearance from the perspective of {{char}} in the form of a comma-delimited list of keywords and phrases. The list must include all of the following items in this order: name, species and race, gender, age, clothing, occupation, physical features and appearances. Do not include descriptions of non-visual qualities such as personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'full body portrait,\'. Ignore the rest of the story when crafting this description. Do not reply as {{char}} when writing this description, and do not attempt to continue the story.',
190 [generationMode.SCENARIO]: 'Ignore previous instructions and provide a detailed description for all of the following: a brief recap of recent events in the story, {{char}}\'s appearance, and {{char}}\'s surroundings. Do not reply as {{char}} while writing this description.',
191
192 [generationMode.NOW]: `Ignore previous instructions. Your next response must be formatted as a single comma-delimited list of concise keywords. The list will describe of the visual details included in the last chat message.
193
194 Only mention characters by using pronouns ('he','his','she','her','it','its') or neutral nouns ('male', 'the man', 'female', 'the woman').
195
196 Ignore non-visible things such as feelings, personality traits, thoughts, and spoken dialog.
197
198 Add keywords in this precise order:
199 a keyword to describe the location of the scene,
200 a keyword to mention how many characters of each gender or type are present in the scene (minimum of two characters:
201 {{user}} and {{char}}, example: '2 men ' or '1 man 1 woman ', '1 man 3 robots'),
202
203 keywords to describe the relative physical positioning of the characters to each other (if a commonly known term for the positioning is known use it instead of describing the positioning in detail) + 'POV',
204
205 a single keyword or phrase to describe the primary act taking place in the last chat message,
206
207 keywords to describe {{char}}'s physical appearance and facial expression,
208 keywords to describe {{char}}'s actions,
209 keywords to describe {{user}}'s physical appearance and actions.
210
211 If character actions involve direct physical interaction with another character, mention specifically which body parts interacting and how.
212
213 A correctly formatted example response would be:
214 '(location),(character list by gender),(primary action), (relative character position) POV, (character 1's description and actions), (character 2's description and actions)'`,
215
216 [generationMode.RAW_LAST]: 'Ignore previous instructions and provide ONLY the last chat message string back to me verbatim. Do not write anything after the string. Do not reply as {{char}} when writing this description, and do not attempt to continue the story.',
217 [generationMode.BACKGROUND]: 'Ignore previous instructions and provide a detailed description of {{char}}\'s surroundings in the form of a comma-delimited list of keywords and phrases. The list must include all of the following items in this order: location, time of day, weather, lighting, and any other relevant details. Do not include descriptions of characters and non-visual qualities such as names, personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'background,\'. Ignore the rest of the story when crafting this description. Do not reply as {{char}} when writing this description, and do not attempt to continue the story.',
218 [generationMode.FACE_MULTIMODAL]: 'Provide an exhaustive comma-separated list of tags describing the appearance of the character on this image in great detail. Start with "close-up portrait".',
219 [generationMode.CHARACTER_MULTIMODAL]: 'Provide an exhaustive comma-separated list of tags describing the appearance of the character on this image in great detail. Start with "full body portrait".',
220 [generationMode.USER_MULTIMODAL]: 'Provide an exhaustive comma-separated list of tags describing the appearance of the character on this image in great detail. Start with "full body portrait".',
221 [generationMode.FREE_EXTENDED]: 'Ignore previous instructions and provide an exhaustive comma-separated list of tags describing the appearance of "{0}" in great detail. Start with {{charPrefix}} (sic) if the subject is associated with {{char}}.',
222};
223
224const defaultPrefix = 'best quality, absurdres, aesthetic,';
225const defaultNegative = 'lowres, bad anatomy, bad hands, text, error, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry';
226
227const defaultStyles = [
228 {
229 name: 'Default',
230 negative: defaultNegative,
231 prefix: defaultPrefix,
232 },
233];
234
235const placeholderVae = 'Automatic';
236
237const defaultSettings = {
238 source: sources.extras,
239
240 // CFG Scale
241 scale_min: 1,
242 scale_max: 30,
243 scale_step: 0.1,
244 scale: 7,
245
246 // Sampler steps
247 steps_min: 1,
248 steps_max: 150,
249 steps_step: 1,
250 steps: 20,
251
252 // Scheduler
253 scheduler: 'normal',
254
255 // Image dimensions (Width & Height)
256 dimension_min: 64,
257 dimension_max: 2048,
258 dimension_step: 64,
259 width: 512,
260 height: 512,
261
262 prompt_prefix: defaultPrefix,
263 negative_prompt: defaultNegative,
264 sampler: 'DDIM',
265 model: '',
266 vae: '',
267 seed: -1,
268
269 // Automatic1111/Horde exclusives
270 restore_faces: false,
271 enable_hr: false,
272 adetailer_face: false,
273
274 // Horde settings
275 horde: false,
276 horde_nsfw: false,
277 horde_karras: true,
278 horde_sanitize: true,
279
280 // Refine mode
281 refine_mode: false,
282 interactive_mode: false,
283 multimodal_captioning: false,
284 snap: false,
285 free_extend: false,
286 function_tool: false,
287 minimal_prompt_processing: false,
288
289 prompts: promptTemplates,
290
291 // AUTOMATIC1111 settings
292 auto_url: 'http://localhost:7860',
293 auto_auth: '',
294
295 // stable-diffusion.cpp settings
296 sdcpp_url: 'http://127.0.0.1:1234',
297
298 vlad_url: 'http://localhost:7860',
299 vlad_auth: '',
300
301 drawthings_url: 'http://localhost:7860',
302 drawthings_auth: '',
303
304 hr_upscaler: 'Latent',
305 hr_scale: 1.0,
306 hr_scale_min: 1.0,
307 hr_scale_max: 4.0,
308 hr_scale_step: 0.1,
309 denoising_strength: 0.7,
310 denoising_strength_min: 0.0,
311 denoising_strength_max: 1.0,
312 denoising_strength_step: 0.01,
313 hr_second_pass_steps: 0,
314 hr_second_pass_steps_min: 0,
315 hr_second_pass_steps_max: 150,
316 hr_second_pass_steps_step: 1,
317
318 // CLIP skip
319 clip_skip_min: 1,
320 clip_skip_max: 12,
321 clip_skip_step: 1,
322 clip_skip: 1,
323
324 // NovelAI settings
325 novel_anlas_guard: false,
326 novel_sm: false,
327 novel_sm_dyn: false,
328 novel_decrisper: false,
329 novel_variety_boost: false,
330
331 // OpenAI settings
332 openai_style: 'vivid',
333 openai_quality: 'standard',
334 openai_quality_gpt: 'auto',
335 openai_duration: '8',
336
337 style: 'Default',
338 styles: defaultStyles,
339
340 // ComyUI settings
341 comfy_type: 'standard',
342
343 comfy_url: 'http://127.0.0.1:8188',
344 comfy_workflow: 'Default_Comfy_Workflow.json',
345
346 comfy_runpod_url: '',
347
348 // Pollinations settings
349 pollinations_enhance: false,
350
351 // Visibility toggles
352 wand_visible: false,
353 command_visible: false,
354 interactive_visible: false,
355 tool_visible: false,
356
357 // Stability AI settings
358 stability_style_preset: 'anime',
359
360 // BFL API settings
361 bfl_upsampling: false,
362
363 // Google settings
364 google_api: 'makersuite',
365 google_enhance: true,
366 google_duration: 6,
367
368 // Settings presets & auto-fallback ({ name, preset } entries, tried in order)
369 settings_preset_chain: [],
370 settings_fallback_enabled: false,
371
372 // Reference image library ({ tag, description, path } entries)
373 ref_images_enabled: false,
374 ref_images: [],
375
376 // RunPod lazy-pod proxy base URL ('' = feature off)
377 runpod_lazy_url: '',
378
379 // RunPod model catalog ({ name, value, kind: 'model'|'lora', downloads } entries)
380 runpod_models: [],
381
382 // Selected LoRA for comfy workflows using the %lora% placeholder
383 lora: '',
384 lora_strength: 1.0,
385 lora_strength_min: 0.0,
386 lora_strength_max: 2.0,
387 lora_strength_step: 0.01,
388
389 // Per-workflow remembered selections ({ [workflow]: { model, lora } });
390 // part of preset snapshots, so each chain provider keeps its own map.
391 comfy_workflow_prefs: {},
392
393 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
394 prompt_generation_profile: '',
395
396 // User-defined custom wand dropdown entries ({ id, title, prompt })
397 custom_entries: [],
398};
399
400/**
401 * Keys that are NOT part of a settings preset snapshot.
402 * A preset captures backend/connection params (source, model, sampler, dimensions, etc.)
403 * but not the prompt library, styles, custom entries, or UI prefs.
404 * @type {string[]}
405 */
406const PRESET_EXCLUDE_KEYS = [
407 'settings_preset_chain',
408 'settings_fallback_enabled',
409 // The reference image library is global, not a per-backend setting.
410 'ref_images_enabled',
411 'ref_images',
412 'runpod_lazy_url',
413 'runpod_models',
414 // The image-prompt LLM profile is independent of the image backend, so it must
415 // never be captured/swapped by image-generation presets or the fallback retry.
416 'prompt_generation_profile',
417 'prompts',
418 'character_prompts',
419 'character_negative_prompts',
420 'styles',
421 'custom_entries',
422 'interactive_visible',
423 'wand_visible',
424 'command_visible',
425 'tool_visible',
426 'expand',
427];
428
429/**
430 * Creates a deep clone snapshot of the settings keys that belong to a preset.
431 * @returns {object} Snapshot of the included settings keys.
432 */
433function snapshotSdSettings() {
434 const snapshot = {};
435 for (const key of Object.keys(extension_settings.sd)) {
436 if (PRESET_EXCLUDE_KEYS.includes(key)) {
437 continue;
438 }
439 snapshot[key] = structuredClone(extension_settings.sd[key]);
440 }
441 return snapshot;
442}
443
444/**
445 * Applies a settings snapshot to the live settings object (in-memory only; does NOT touch the DOM).
446 * @param {object} snapshot Snapshot previously created by snapshotSdSettings().
447 */
448function applySdSettingsSnapshot(snapshot) {
449 if (!snapshot || typeof snapshot !== 'object') {
450 return;
451 }
452 for (const key of Object.keys(snapshot)) {
453 // Skip excluded keys so older presets that were saved before a key was
454 // excluded (e.g. prompt_generation_profile) can't clobber it on load.
455 if (PRESET_EXCLUDE_KEYS.includes(key)) {
456 continue;
457 }
458 extension_settings.sd[key] = structuredClone(snapshot[key]);
459 }
460}
461
462/**
463 * Checks whether a preset is configured (a non-null object with at least one key).
464 * @param {object} preset Preset to check.
465 * @returns {boolean} True if the preset is configured.
466 */
467function isPresetConfigured(preset) {
468 return !!preset && typeof preset === 'object' && Object.keys(preset).length > 0;
469}
470
471/**
472 * Returns the fallback chain entries that hold a usable preset snapshot, in order.
473 * @returns {{name: string, preset: object}[]} Ordered list of configured chain entries.
474 */
475function getConfiguredPresetChain() {
476 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
477 return chain.filter(entry => entry && isPresetConfigured(entry.preset));
478}
479
480/**
481 * How long to wait for a locally-hosted backend's status endpoint before treating
482 * the server as down and moving on to the next entry in the fallback chain.
483 */
484const SOURCE_PROBE_TIMEOUT_MS = 1500;
485
486/**
487 * Quickly checks whether the currently configured source is up. Only locally-hosted
488 * backends with a status endpoint are probed (ComfyUI, A1111, SD.Next, DrawThings,
489 * stable-diffusion.cpp); sources without a probe are assumed reachable.
490 * @returns {Promise<boolean>} False when the backend has a probe and it failed.
491 */
492async function isCurrentSourceReachable() {
493 /**
494 * @param {string} endpoint ST server ping route for the backend.
495 * @param {object} body Request body identifying the backend server.
496 * @returns {Promise<boolean>} Whether the ping succeeded within the timeout.
497 */
498 const probe = async (endpoint, body) => {
499 try {
500 const result = await fetch(endpoint, {
501 method: 'POST',
502 headers: getRequestHeaders(),
503 signal: AbortSignal.timeout(SOURCE_PROBE_TIMEOUT_MS),
504 body: JSON.stringify(body),
505 });
506 return result.ok;
507 } catch {
508 return false;
509 }
510 };
511
512 switch (extension_settings.sd.source) {
513 case sources.comfy:
514 if (extension_settings.sd.comfy_type !== comfyTypes.standard) {
515 return true;
516 }
517 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
518 return isRunpodReady(SOURCE_PROBE_TIMEOUT_MS);
519 }
520 return probe('/api/sd/comfy/ping', { url: extension_settings.sd.comfy_url });
521 case sources.auto:
522 case sources.vlad:
523 case sources.drawthings:
524 return probe(extension_settings.sd.source === sources.drawthings ? '/api/sd/drawthings/ping' : '/api/sd/ping', getSdRequestBody());
525 case sources.sdcpp:
526 return probe('/api/sd/sdcpp/ping', { url: extension_settings.sd.sdcpp_url });
527 default:
528 return true;
529 }
530}
531
532/**
533 * Refreshes all settings UI controls to reflect the current extension_settings.sd values.
534 * Used after loading a settings preset.
535 * @returns {Promise<void>}
536 */
537async function refreshSettingsUi() {
538 // loadSettings() appends to #sd_style without clearing it, so empty it first.
539 $('#sd_style').empty();
540 await loadSettings();
541}
542
543const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
544const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase());
545
546/**
547 * Generate interceptor for interactive mode triggers.
548 * @param {any[]} chat Chat messages
549 * @param {number} _ Context size (unused)
550 * @param {function(boolean): void} abort Abort generation function
551 * @param {string} type Type of the generation
552 */
553function processTriggers(chat, _, abort, type) {
554 if (type === 'quiet') {
555 return;
556 }
557
558 if (extension_settings.sd.function_tool && ToolManager.isToolCallingSupported()) {
559 return;
560 }
561
562 if (!extension_settings.sd.interactive_mode) {
563 return;
564 }
565
566 const lastMessage = chat[chat.length - 1];
567
568 if (!lastMessage) {
569 return;
570 }
571
572 const message = lastMessage.mes;
573 const isUser = lastMessage.is_user;
574
575 if (!message || !isUser) {
576 return;
577 }
578
579 const messageLower = message.toLowerCase();
580
581 try {
582 const activationRegex = new RegExp(messageTrigger.activationRegex, 'i');
583 const activationMatch = messageLower.match(activationRegex);
584
585 if (!activationMatch) {
586 return;
587 }
588
589 let subject = activationMatch[3].trim();
590
591 if (!subject) {
592 return;
593 }
594
595 console.log(`SD: Triggered by "${message}", detected subject: "${subject}"`);
596
597 outer: for (const [specialMode, triggers] of Object.entries(messageTrigger.specialCases)) {
598 for (const trigger of triggers) {
599 if (subject === trigger) {
600 subject = triggerWords[specialMode][0];
601 console.log(`SD: Detected special case "${trigger}", switching to mode ${specialMode}`);
602 break outer;
603 }
604 }
605 }
606
607 abort(true);
608 setTimeout(() => generatePicture(initiators.interactive, {}, subject, message), 1);
609 } catch {
610 console.log('SD: Failed to process triggers.');
611 }
612}
613
614globalThis.SD_ProcessTriggers = processTriggers;
615
616function getSdRequestBody() {
617 switch (extension_settings.sd.source) {
618 case sources.vlad:
619 return { url: extension_settings.sd.vlad_url, auth: extension_settings.sd.vlad_auth };
620 case sources.auto:
621 return { url: extension_settings.sd.auto_url, auth: extension_settings.sd.auto_auth };
622 case sources.drawthings:
623 return { url: extension_settings.sd.drawthings_url, auth: extension_settings.sd.drawthings_auth };
624 default:
625 throw new Error('Invalid SD source.');
626 }
627}
628
629function toggleSourceControls() {
630 $('.sd_settings [data-sd-source]').each(function () {
631 const source = $(this).data('sd-source').split(',');
632 $(this).toggle(source.includes(extension_settings.sd.source));
633 });
634 $('.sd_settings [data-sd-comfy-type]').each(function () {
635 const source = $(this).data('sd-comfy-type').split(',');
636 $(this).toggle(source.includes(extension_settings.sd.comfy_type));
637 });
638}
639
640let promptGenerationProfileDropdownInitialized = false;
641// Warn at most once per session if a prompt-gen profile is selected but cannot be
642// applied because there is no active connection profile to restore afterward.
643let promptProfileWarnedNoBaseProfile = false;
644
645/**
646 * Populates the dedicated prompt-generation connection profile dropdown.
647 * Only initializes once to avoid attaching duplicate Connection Manager event listeners
648 * when loadSettings() is called again (e.g. after loading a settings preset).
649 */
650function initPromptGenerationProfileDropdown() {
651 if (promptGenerationProfileDropdownInitialized) {
652 return;
653 }
654
655 try {
656 ConnectionManagerRequestService.handleDropdown(
657 '#sd_prompt_generation_profile',
658 extension_settings.sd.prompt_generation_profile,
659 (profile) => {
660 extension_settings.sd.prompt_generation_profile = profile?.id ?? '';
661 saveSettingsDebounced();
662 },
663 );
664 promptGenerationProfileDropdownInitialized = true;
665 } catch (error) {
666 // Connection Manager may be unavailable/disabled; leave the dropdown empty in that case.
667 console.warn('SD: could not populate prompt-generation profile dropdown', error);
668 }
669}
670
671async function loadSettings() {
672 // Initialize settings
673 if (Object.keys(extension_settings.sd).length === 0) {
674 Object.assign(extension_settings.sd, defaultSettings);
675 }
676
677 // Insert missing settings
678 for (const [key, value] of Object.entries(defaultSettings)) {
679 if (extension_settings.sd[key] === undefined) {
680 extension_settings.sd[key] = value;
681 }
682 }
683
684 if (extension_settings.sd.prompts === undefined) {
685 extension_settings.sd.prompts = promptTemplates;
686 }
687
688 // Insert missing templates
689 for (const [key, value] of Object.entries(promptTemplates)) {
690 if (extension_settings.sd.prompts[key] === undefined) {
691 extension_settings.sd.prompts[key] = value;
692 }
693 }
694
695 if (extension_settings.sd.character_prompts === undefined) {
696 extension_settings.sd.character_prompts = {};
697 }
698
699 if (extension_settings.sd.character_negative_prompts === undefined) {
700 extension_settings.sd.character_negative_prompts = {};
701 }
702
703 if (!Array.isArray(extension_settings.sd.styles)) {
704 extension_settings.sd.styles = defaultStyles;
705 }
706
707 // Settings presets & auto-fallback
708 if (!Array.isArray(extension_settings.sd.settings_preset_chain)) {
709 // Migrate the old two-slot primary/secondary presets into a chain.
710 const chain = [];
711 if (isPresetConfigured(extension_settings.sd.settings_preset_primary)) {
712 chain.push({ name: 'Primary', preset: extension_settings.sd.settings_preset_primary });
713 }
714 if (isPresetConfigured(extension_settings.sd.settings_preset_secondary)) {
715 chain.push({ name: 'Secondary', preset: extension_settings.sd.settings_preset_secondary });
716 }
717 extension_settings.sd.settings_preset_chain = chain;
718 delete extension_settings.sd.settings_preset_primary;
719 delete extension_settings.sd.settings_preset_secondary;
720 }
721
722 if (extension_settings.sd.settings_fallback_enabled === undefined) {
723 extension_settings.sd.settings_fallback_enabled = false;
724 }
725
726 // Reference image library
727 if (extension_settings.sd.ref_images_enabled === undefined) {
728 extension_settings.sd.ref_images_enabled = false;
729 }
730
731 if (!Array.isArray(extension_settings.sd.ref_images)) {
732 extension_settings.sd.ref_images = [];
733 }
734
735 if (!Array.isArray(extension_settings.sd.runpod_models)) {
736 extension_settings.sd.runpod_models = [];
737 }
738
739 if (extension_settings.sd.lora === undefined) {
740 extension_settings.sd.lora = '';
741 }
742
743 if (typeof extension_settings.sd.lora_strength !== 'number') {
744 extension_settings.sd.lora_strength = 1.0;
745 }
746
747 // The flux workflows moved from a hardcoded flux2 VAE to %vae%; a leftover
748 // flux1 selection ('ae.safetensors') would fail pod-side validation.
749 const migrateVae = (config) => {
750 if (config && config.vae === 'ae.safetensors') {
751 config.vae = 'flux2-vae.safetensors';
752 }
753 };
754 migrateVae(extension_settings.sd);
755 (extension_settings.sd.settings_preset_chain ?? []).forEach(entry => migrateVae(entry?.preset));
756
757 if (typeof extension_settings.sd.comfy_workflow_prefs !== 'object' || !extension_settings.sd.comfy_workflow_prefs) {
758 extension_settings.sd.comfy_workflow_prefs = {};
759 }
760
761 if (!Array.isArray(extension_settings.sd.custom_entries)) {
762 extension_settings.sd.custom_entries = [];
763 }
764
765 // Preserve an original seed if exists
766 if (extension_settings.sd.original_seed >= 0) {
767 extension_settings.sd.seed = extension_settings.sd.original_seed;
768 delete extension_settings.sd.original_seed;
769 }
770
771 $('#sd_source').val(extension_settings.sd.source);
772 $('#sd_scale').val(extension_settings.sd.scale).trigger('input');
773 $('#sd_steps').val(extension_settings.sd.steps).trigger('input');
774 $('#sd_prompt_prefix').val(extension_settings.sd.prompt_prefix).trigger('input');
775 $('#sd_negative_prompt').val(extension_settings.sd.negative_prompt).trigger('input');
776 $('#sd_width').val(extension_settings.sd.width).trigger('input');
777 $('#sd_height').val(extension_settings.sd.height).trigger('input');
778 $('#sd_hr_scale').val(extension_settings.sd.hr_scale).trigger('input');
779 $('#sd_denoising_strength').val(extension_settings.sd.denoising_strength).trigger('input');
780 $('#sd_lora_strength').val(extension_settings.sd.lora_strength ?? 1.0).trigger('input');
781 $('#sd_hr_second_pass_steps').val(extension_settings.sd.hr_second_pass_steps).trigger('input');
782 $('#sd_novel_anlas_guard').prop('checked', extension_settings.sd.novel_anlas_guard);
783 $('#sd_novel_sm').prop('checked', extension_settings.sd.novel_sm);
784 $('#sd_novel_sm_dyn').prop('checked', extension_settings.sd.novel_sm_dyn);
785 $('#sd_novel_sm_dyn').prop('disabled', !extension_settings.sd.novel_sm);
786 $('#sd_novel_decrisper').prop('checked', extension_settings.sd.novel_decrisper);
787 $('#sd_novel_variety_boost').prop('checked', extension_settings.sd.novel_variety_boost);
788 $('#sd_pollinations_enhance').prop('checked', extension_settings.sd.pollinations_enhance);
789 $('#sd_horde').prop('checked', extension_settings.sd.horde);
790 $('#sd_horde_nsfw').prop('checked', extension_settings.sd.horde_nsfw);
791 $('#sd_horde_karras').prop('checked', extension_settings.sd.horde_karras);
792 $('#sd_horde_sanitize').prop('checked', extension_settings.sd.horde_sanitize);
793 $('#sd_restore_faces').prop('checked', extension_settings.sd.restore_faces);
794 $('#sd_enable_hr').prop('checked', extension_settings.sd.enable_hr);
795 $('#sd_adetailer_face').prop('checked', extension_settings.sd.adetailer_face);
796 $('#sd_refine_mode').prop('checked', extension_settings.sd.refine_mode);
797 $('#sd_multimodal_captioning').prop('checked', extension_settings.sd.multimodal_captioning);
798 $('#sd_auto_url').val(extension_settings.sd.auto_url);
799 $('#sd_auto_auth').val(extension_settings.sd.auto_auth);
800 $('#sd_sdcpp_url').val(extension_settings.sd.sdcpp_url);
801 $('#sd_vlad_url').val(extension_settings.sd.vlad_url);
802 $('#sd_vlad_auth').val(extension_settings.sd.vlad_auth);
803 $('#sd_drawthings_url').val(extension_settings.sd.drawthings_url);
804 $('#sd_drawthings_auth').val(extension_settings.sd.drawthings_auth);
805 $('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode);
806 $('#sd_openai_style').val(extension_settings.sd.openai_style);
807 $('#sd_openai_quality').val(extension_settings.sd.openai_quality);
808 $('#sd_openai_quality_gpt').val(extension_settings.sd.openai_quality_gpt);
809 $('#sd_openai_duration').val(extension_settings.sd.openai_duration);
810 $('#sd_comfy_type').val(extension_settings.sd.comfy_type);
811 $('#sd_comfy_url').val(extension_settings.sd.comfy_url);
812 $('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt);
813 $('#sd_comfy_runpod_url').val(extension_settings.sd.comfy_runpod_url);
814 $('#sd_snap').prop('checked', extension_settings.sd.snap);
815 $('#sd_minimal_prompt_processing').prop('checked', extension_settings.sd.minimal_prompt_processing);
816 $('#sd_clip_skip').val(extension_settings.sd.clip_skip);
817 $('#sd_clip_skip_value').val(extension_settings.sd.clip_skip);
818 $('#sd_seed').val(extension_settings.sd.seed);
819 $('#sd_free_extend').prop('checked', extension_settings.sd.free_extend);
820 $('#sd_wand_visible').prop('checked', extension_settings.sd.wand_visible);
821 $('#sd_command_visible').prop('checked', extension_settings.sd.command_visible);
822 $('#sd_interactive_visible').prop('checked', extension_settings.sd.interactive_visible);
823 $('#sd_tool_visible').prop('checked', extension_settings.sd.tool_visible);
824 $('#sd_stability_style_preset').val(extension_settings.sd.stability_style_preset);
825 $('#sd_huggingface_model_id').val(extension_settings.sd.huggingface_model_id);
826 $('#sd_function_tool').prop('checked', extension_settings.sd.function_tool);
827 $('#sd_bfl_upsampling').prop('checked', extension_settings.sd.bfl_upsampling);
828 $('#sd_google_api').val(extension_settings.sd.google_api);
829 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);
830 $('#sd_google_duration').val(extension_settings.sd.google_duration);
831 $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled);
832 $('#sd_ref_images_enabled').prop('checked', extension_settings.sd.ref_images_enabled);
833 $('#sd_runpod_lazy_url').val(extension_settings.sd.runpod_lazy_url ?? '');
834 renderPresetChain();
835 renderRefImages();
836 renderRunpodModels();
837 setupRunpodLoops();
838
839 for (const style of extension_settings.sd.styles) {
840 const option = document.createElement('option');
841 option.value = style.name;
842 option.text = style.name;
843 option.selected = style.name === extension_settings.sd.style;
844 $('#sd_style').append(option);
845 }
846
847 const resolutionId = getClosestKnownResolution();
848 $('#sd_resolution').val(resolutionId);
849
850 initPromptGenerationProfileDropdown();
851
852 toggleSourceControls();
853 addPromptTemplates();
854 renderCustomEntriesList();
855 renderCustomDropdownEntries();
856 registerFunctionTool();
857
858 await loadSettingOptions();
859}
860
861/**
862 * Find a closest resolution option match for the current width and height.
863 */
864function getClosestKnownResolution() {
865 let resolutionId = null;
866 let minTotalDiff = Infinity;
867
868 const targetAspect = extension_settings.sd.width / extension_settings.sd.height;
869 const targetResolution = extension_settings.sd.width * extension_settings.sd.height;
870
871 const diffs = Object.entries(resolutionOptions).map(([id, resolution]) => {
872 const aspectDiff = Math.abs((resolution.width / resolution.height) - targetAspect) / targetAspect;
873 const resolutionDiff = Math.abs(resolution.width * resolution.height - targetResolution) / targetResolution;
874 return { id, totalDiff: aspectDiff + resolutionDiff };
875 });
876
877 for (const { id, totalDiff } of diffs) {
878 if (totalDiff < minTotalDiff) {
879 minTotalDiff = totalDiff;
880 resolutionId = id;
881 }
882 }
883
884 return resolutionId;
885}
886
887async function loadSettingOptions() {
888 return Promise.all([
889 loadSamplers(),
890 loadModels(),
891 loadSchedulers(),
892 loadVaes(),
893 loadLoras(),
894 loadComfyWorkflows(),
895 ]);
896}
897
898async function loadLoras() {
899 $('#sd_lora').empty();
900 let loras = ['N/A'];
901 if (extension_settings.sd.source === sources.comfy && extension_settings.sd.comfy_type === comfyTypes.standard) {
902 loras = await loadComfyLoras();
903 }
904 for (const lora of loras) {
905 const option = document.createElement('option');
906 option.innerText = lora?.text ?? lora;
907 option.value = lora?.value ?? lora;
908 option.selected = option.value === extension_settings.sd.lora;
909 $('#sd_lora').append(option);
910 }
911}
912
913async function loadComfyLoras() {
914 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
915 return getRunpodCatalog().filter(m => m.kind === 'lora').map(m => ({ value: m.value, text: m.name || m.value }));
916 }
917 if (!extension_settings.sd.comfy_url) {
918 return [];
919 }
920 try {
921 const result = await fetch('/api/sd/comfy/loras', {
922 method: 'POST',
923 headers: getRequestHeaders(),
924 body: JSON.stringify({ url: extension_settings.sd.comfy_url }),
925 });
926 if (!result.ok) {
927 throw new Error('ComfyUI returned an error.');
928 }
929 return await result.json();
930 } catch (error) {
931 return [];
932 }
933}
934
935function addPromptTemplates() {
936 $('#sd_prompt_templates').empty();
937
938 for (const [name, prompt] of Object.entries(extension_settings.sd.prompts).sort((a, b) => Number(a[0]) - Number(b[0]))) {
939 const label = $('<label></label>')
940 .text(modeLabels[name])
941 .attr('for', `sd_prompt_${name}`)
942 .attr('data-i18n', `sd_prompt_${name}`);
943 const textarea = $('<textarea></textarea>')
944 .addClass('textarea_compact text_pole')
945 .attr('id', `sd_prompt_${name}`)
946 .attr('rows', 3)
947 .val(prompt).on('input', () => {
948 extension_settings.sd.prompts[name] = textarea.val();
949 saveSettingsDebounced();
950 });
951 const button = $('<button></button>')
952 .addClass('menu_button fa-solid fa-undo')
953 .attr('title', 'Restore default')
954 .attr('data-i18n', 'Restore default')
955 .on('click', () => {
956 textarea.val(promptTemplates[name]);
957 extension_settings.sd.prompts[name] = promptTemplates[name];
958 if (String(name) === String(generationMode.TOOL)) {
959 registerFunctionTool();
960 }
961 saveSettingsDebounced();
962 });
963 const container = $('<div></div>')
964 .addClass('title_restorable')
965 .append(label)
966 .append(button);
967 $('#sd_prompt_templates').append(container);
968 $('#sd_prompt_templates').append(textarea);
969 }
970}
971
972function onInteractiveModeInput() {
973 extension_settings.sd.interactive_mode = !!$(this).prop('checked');
974 saveSettingsDebounced();
975}
976
977function onMultimodalCaptioningInput() {
978 extension_settings.sd.multimodal_captioning = !!$(this).prop('checked');
979 saveSettingsDebounced();
980}
981
982function onSnapInput() {
983 extension_settings.sd.snap = !!$(this).prop('checked');
984 saveSettingsDebounced();
985}
986
987function onMinimalPromptProcessing() {
988 extension_settings.sd.minimal_prompt_processing = !!$(this).prop('checked');
989 saveSettingsDebounced();
990}
991
992function onStyleSelect() {
993 const selectedStyle = String($('#sd_style').find(':selected').val());
994 const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
995
996 if (!styleObject) {
997 console.warn(`Could not find style object for ${selectedStyle}`);
998 return;
999 }
1000
1001 $('#sd_prompt_prefix').val(styleObject.prefix).trigger('input');
1002 $('#sd_negative_prompt').val(styleObject.negative).trigger('input');
1003 extension_settings.sd.style = selectedStyle;
1004 saveSettingsDebounced();
1005}
1006
1007async function onDeleteStyleClick() {
1008 const selectedStyle = String($('#sd_style').find(':selected').val());
1009 const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
1010
1011 if (!styleObject) {
1012 return;
1013 }
1014
1015 const confirmed = await callGenericPopup(t`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
1016
1017 if (!confirmed) {
1018 return;
1019 }
1020
1021 const index = extension_settings.sd.styles.indexOf(styleObject);
1022
1023 if (index === -1) {
1024 return;
1025 }
1026
1027 extension_settings.sd.styles.splice(index, 1);
1028 $('#sd_style').find(`option[value="${selectedStyle}"]`).remove();
1029
1030 if (extension_settings.sd.styles.length > 0) {
1031 extension_settings.sd.style = extension_settings.sd.styles[0].name;
1032 $('#sd_style').val(extension_settings.sd.style).trigger('change');
1033 } else {
1034 extension_settings.sd.style = '';
1035 $('#sd_prompt_prefix').val('').trigger('input');
1036 $('#sd_negative_prompt').val('').trigger('input');
1037 $('#sd_style').val('');
1038 }
1039
1040 saveSettingsDebounced();
1041}
1042
1043async function onSaveStyleClick() {
1044 const selectedStyle = extension_settings.sd.style || '';
1045 const userInput = await callGenericPopup(t`Enter style name:`, POPUP_TYPE.INPUT, selectedStyle);
1046
1047 if (!userInput) {
1048 return;
1049 }
1050
1051 const name = String(userInput).trim();
1052 const prefix = String($('#sd_prompt_prefix').val());
1053 const negative = String($('#sd_negative_prompt').val());
1054
1055 const existingStyle = extension_settings.sd.styles.find(x => x.name === name);
1056
1057 if (existingStyle) {
1058 existingStyle.prefix = prefix;
1059 existingStyle.negative = negative;
1060 $('#sd_style').val(name);
1061 saveSettingsDebounced();
1062 return;
1063 }
1064
1065 const styleObject = {
1066 name: name,
1067 prefix: prefix,
1068 negative: negative,
1069 };
1070
1071 extension_settings.sd.styles.push(styleObject);
1072 const option = document.createElement('option');
1073 option.value = styleObject.name;
1074 option.text = styleObject.name;
1075 option.selected = true;
1076 $('#sd_style').append(option);
1077 $('#sd_style').val(styleObject.name);
1078 saveSettingsDebounced();
1079}
1080
1081async function onRenameStyleClick() {
1082 const selectedStyle = extension_settings.sd.style;
1083 const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
1084
1085 if (!styleObject) {
1086 return;
1087 }
1088
1089 const newName = await callGenericPopup(t`Enter new style name:`, POPUP_TYPE.INPUT, selectedStyle);
1090
1091 if (!newName) {
1092 return;
1093 }
1094
1095 const name = String(newName).trim();
1096
1097 if (name === selectedStyle) {
1098 return;
1099 }
1100
1101 const existingStyle = extension_settings.sd.styles.find(x => x.name === name);
1102
1103 if (existingStyle) {
1104 toastr.error(t`A style with that name already exists`);
1105 return;
1106 }
1107
1108 styleObject.name = name;
1109 extension_settings.sd.style = name;
1110
1111 $('#sd_style').empty();
1112 for (const style of extension_settings.sd.styles) {
1113 const option = document.createElement('option');
1114 option.value = style.name;
1115 option.text = style.name;
1116 option.selected = style.name === extension_settings.sd.style;
1117 $('#sd_style').append(option);
1118 }
1119
1120 saveSettingsDebounced();
1121}
1122
1123/**
1124 * Rebuilds the provider fallback chain list in the settings UI.
1125 */
1126function renderPresetChain() {
1127 const container = $('#sd_preset_chain_list');
1128 if (!container.length) {
1129 return;
1130 }
1131
1132 container.empty();
1133
1134 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
1135
1136 if (chain.length === 0) {
1137 const empty = $('<small></small>')
1138 .attr('data-i18n', 'No presets in the chain yet.')
1139 .text('No presets in the chain yet.');
1140 container.append(empty);
1141 return;
1142 }
1143
1144 chain.forEach((entry, index) => {
1145 const orderEl = $('<div></div>').addClass('sd_preset_chain_order').text(`${index + 1}.`);
1146 const sourceHint = String(entry.preset?.source ?? '');
1147 const nameInput = $('<input>')
1148 .addClass('text_pole flex1')
1149 .attr('type', 'text')
1150 .attr('title', sourceHint ? `Source: ${sourceHint}` : '')
1151 .val(entry.name || '')
1152 .on('change', function () {
1153 entry.name = String($(this).val() ?? '').trim() || `Preset ${index + 1}`;
1154 saveSettingsDebounced();
1155 });
1156
1157 const makeButton = (icon, title, handler) => $('<div></div>')
1158 .addClass(`menu_button menu_button_icon fa-solid ${icon}`)
1159 .attr('title', title)
1160 .attr('data-i18n', `[title]${title}`)
1161 .on('click', handler);
1162
1163 const upButton = makeButton('fa-chevron-up', 'Move up', () => {
1164 if (index === 0) return;
1165 [chain[index - 1], chain[index]] = [chain[index], chain[index - 1]];
1166 saveSettingsDebounced();
1167 renderPresetChain();
1168 });
1169 const downButton = makeButton('fa-chevron-down', 'Move down', () => {
1170 if (index === chain.length - 1) return;
1171 [chain[index + 1], chain[index]] = [chain[index], chain[index + 1]];
1172 saveSettingsDebounced();
1173 renderPresetChain();
1174 });
1175 const loadButton = makeButton('fa-file-import', 'Load this preset into the current settings', async () => {
1176 applySdSettingsSnapshot(entry.preset);
1177 saveSettingsDebounced();
1178 await refreshSettingsUi();
1179 toastr.success(t`Settings preset loaded.`, t`Image Generation`);
1180 });
1181 const saveButton = makeButton('fa-floppy-disk', 'Overwrite this preset with the current settings', () => {
1182 entry.preset = snapshotSdSettings();
1183 saveSettingsDebounced();
1184 renderPresetChain();
1185 toastr.success(t`Settings preset updated.`, t`Image Generation`);
1186 });
1187 const deleteButton = makeButton('fa-trash-can', 'Remove from the chain', () => {
1188 chain.splice(index, 1);
1189 saveSettingsDebounced();
1190 renderPresetChain();
1191 });
1192
1193 const row = $('<div></div>')
1194 .addClass('flex-container alignItemsCenter marginTopBot5')
1195 .append(orderEl)
1196 .append(nameInput)
1197 .append(upButton)
1198 .append(downButton)
1199 .append(loadButton)
1200 .append(saveButton)
1201 .append(deleteButton);
1202
1203 container.append(row);
1204 });
1205}
1206
1207function onPresetChainAddClick() {
1208 const nameInput = $('#sd_preset_chain_name');
1209 const name = String(nameInput.val() ?? '').trim();
1210 const chain = extension_settings.sd.settings_preset_chain;
1211 chain.push({ name: name || `Preset ${chain.length + 1}`, preset: snapshotSdSettings() });
1212 nameInput.val('');
1213 saveSettingsDebounced();
1214 renderPresetChain();
1215 toastr.success(t`Current settings added to the fallback chain.`, t`Image Generation`);
1216}
1217
1218function onFallbackEnabledChange() {
1219 extension_settings.sd.settings_fallback_enabled = !!$(this).prop('checked');
1220 saveSettingsDebounced();
1221}
1222
1223// #region Reference image library
1224
1225/**
1226 * Matches the reference image placeholder in a raw ComfyUI workflow ("%reference_image%" or "%reference-image%").
1227 */
1228const REFERENCE_IMAGE_PLACEHOLDER = /"%reference[-_]image%"/i;
1229
1230/**
1231 * Reference image chosen for the in-flight generation. Set at prompt-generation time
1232 * (or lazily by the workflow builder) and read when the ComfyUI workflow is assembled.
1233 * Deliberately kept across swipe regenerations so a swipe reuses the same reference.
1234 * @type {{tag: string, description: string, path: string} | null}
1235 */
1236let pendingReferenceImage = null;
1237
1238/**
1239 * Returns library entries that have an uploaded image.
1240 * @returns {{tag: string, description: string, path: string}[]} Valid reference images.
1241 */
1242function getValidRefImages() {
1243 const images = Array.isArray(extension_settings.sd.ref_images) ? extension_settings.sd.ref_images : [];
1244 return images.filter(image => image && typeof image.path === 'string' && image.path.length > 0);
1245}
1246
1247/**
1248 * Rebuilds the reference image library list in the settings UI.
1249 */
1250function renderRefImages() {
1251 const container = $('#sd_ref_images_list');
1252 if (!container.length) {
1253 return;
1254 }
1255
1256 container.empty();
1257
1258 const images = Array.isArray(extension_settings.sd.ref_images) ? extension_settings.sd.ref_images : [];
1259
1260 if (images.length === 0) {
1261 const empty = $('<small></small>')
1262 .attr('data-i18n', 'No reference images yet.')
1263 .text('No reference images yet.');
1264 container.append(empty);
1265 return;
1266 }
1267
1268 images.forEach((image, index) => {
1269 const thumb = $('<img>')
1270 .addClass('sd_ref_image_thumb')
1271 .attr('src', image.path)
1272 .attr('alt', image.tag || '');
1273 const tagInput = $('<input>')
1274 .addClass('text_pole')
1275 .attr('type', 'text')
1276 .attr('placeholder', 'Tag')
1277 .attr('data-i18n', '[placeholder]Tag')
1278 .val(image.tag || '')
1279 .on('change', function () {
1280 image.tag = String($(this).val() ?? '').trim();
1281 saveSettingsDebounced();
1282 });
1283 const descriptionInput = $('<input>')
1284 .addClass('text_pole flex1')
1285 .attr('type', 'text')
1286 .attr('placeholder', 'Description (used to pick the best fit)')
1287 .attr('data-i18n', '[placeholder]Description (used to pick the best fit)')
1288 .val(image.description || '')
1289 .on('change', function () {
1290 image.description = String($(this).val() ?? '').trim();
1291 saveSettingsDebounced();
1292 });
1293 const deleteButton = $('<div></div>')
1294 .addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1295 .attr('title', 'Remove reference image')
1296 .attr('data-i18n', '[title]Remove reference image')
1297 .on('click', () => {
1298 images.splice(index, 1);
1299 saveSettingsDebounced();
1300 renderRefImages();
1301 });
1302
1303 const row = $('<div></div>')
1304 .addClass('flex-container alignItemsCenter marginTopBot5')
1305 .append(thumb)
1306 .append(tagInput)
1307 .append(descriptionInput)
1308 .append(deleteButton);
1309
1310 container.append(row);
1311 });
1312}
1313
1314function onRefImagesEnabledChange() {
1315 extension_settings.sd.ref_images_enabled = !!$(this).prop('checked');
1316 saveSettingsDebounced();
1317}
1318
1319async function onRefImagesFileChange() {
1320 const files = Array.from(this.files ?? []);
1321 this.value = '';
1322
1323 for (const file of files) {
1324 try {
1325 const dataUrl = await getBase64Async(file);
1326 const base64 = String(dataUrl).split(',')[1];
1327 const extension = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
1328 const baseName = file.name.replace(/\.[^/.]+$/, '');
1329 const path = await saveBase64AsFile(base64, 'reference-images', baseName, extension);
1330 extension_settings.sd.ref_images.push({ tag: baseName, description: '', path });
1331 } catch (error) {
1332 console.error('SD: failed to add reference image', error);
1333 toastr.error(String(error), t`Image Generation`);
1334 }
1335 }
1336
1337 saveSettingsDebounced();
1338 renderRefImages();
1339}
1340
1341/**
1342 * Collects the ComfyUI workflow file names that could be used by this generation:
1343 * the live settings' workflow plus, when the fallback chain is enabled, the workflow
1344 * of every comfy preset in the chain.
1345 * @returns {string[]} Unique workflow file names.
1346 */
1347function collectCandidateComfyWorkflows() {
1348 const names = new Set();
1349 /** @param {object} config A settings-shaped object (live settings or a preset snapshot). */
1350 const consider = (config) => {
1351 if (config && config.source === sources.comfy && config.comfy_type === comfyTypes.standard && config.comfy_workflow) {
1352 names.add(config.comfy_workflow);
1353 }
1354 };
1355 consider(extension_settings.sd);
1356 if (extension_settings.sd.settings_fallback_enabled) {
1357 for (const entry of getConfiguredPresetChain()) {
1358 consider(entry.preset);
1359 }
1360 }
1361 return [...names];
1362}
1363
1364/**
1365 * Checks whether any workflow this generation could run contains the reference image placeholder.
1366 * @returns {Promise<boolean>} True when a candidate workflow uses the placeholder.
1367 */
1368async function anyCandidateWorkflowUsesReferenceImage() {
1369 for (const fileName of collectCandidateComfyWorkflows()) {
1370 try {
1371 const result = await fetch('/api/sd/comfy/workflow', {
1372 method: 'POST',
1373 headers: getRequestHeaders(),
1374 body: JSON.stringify({ file_name: fileName }),
1375 });
1376 if (!result.ok) {
1377 continue;
1378 }
1379 const workflow = await result.json();
1380 if (REFERENCE_IMAGE_PLACEHOLDER.test(String(workflow))) {
1381 return true;
1382 }
1383 } catch (error) {
1384 console.warn('SD: could not inspect workflow for reference image placeholder', fileName, error);
1385 }
1386 }
1387 return false;
1388}
1389
1390/**
1391 * Returns the reference images to choose from for this generation, or an empty array
1392 * when the feature is disabled, the library is empty, or no candidate workflow uses
1393 * the placeholder.
1394 * @returns {Promise<{tag: string, description: string, path: string}[]>} Selectable reference images.
1395 */
1396async function getEligibleReferenceImages() {
1397 if (!extension_settings.sd.ref_images_enabled) {
1398 return [];
1399 }
1400 const images = getValidRefImages();
1401 if (images.length === 0) {
1402 return [];
1403 }
1404 if (!(await anyCandidateWorkflowUsesReferenceImage())) {
1405 return [];
1406 }
1407 return images;
1408}
1409
1410/**
1411 * Builds the instruction appended to the image-prompt request that makes the LLM
1412 * also pick a reference image, as a machine-readable JSON line.
1413 * @param {{tag: string, description: string}[]} candidates Reference images to choose from.
1414 * @returns {string} Instruction text.
1415 */
1416function buildReferenceSelectionAddendum(candidates) {
1417 const list = candidates.map(x => `- "${x.tag}": ${x.description || 'no description'}`).join('\n');
1418 return [
1419 '',
1420 'After the image prompt, append one final line containing exactly this JSON and nothing else:',
1421 '{"reference_image": "<tag>"}',
1422 'where <tag> is the tag of the reference image whose description best fits the requested scene. Available reference images:',
1423 list,
1424 ].join('\n');
1425}
1426
1427/**
1428 * Finds the library entry whose tag matches the given text.
1429 * @param {string} text Tag text returned by the LLM.
1430 * @param {{tag: string}[]} candidates Reference images to match against.
1431 * @returns {object|null} The matching entry, or null.
1432 */
1433function matchReferenceTag(text, candidates) {
1434 const needle = String(text ?? '').trim().toLowerCase();
1435 if (!needle) {
1436 return null;
1437 }
1438 const tagged = candidates.filter(x => String(x.tag ?? '').trim().length > 0);
1439 return tagged.find(x => x.tag.trim().toLowerCase() === needle)
1440 ?? tagged.find(x => needle.includes(x.tag.trim().toLowerCase()))
1441 ?? null;
1442}
1443
1444/**
1445 * Extracts the {"reference_image": "..."} selection from a combined prompt+selection reply.
1446 * @param {string} reply Raw LLM reply.
1447 * @param {{tag: string}[]} candidates Reference images to match against.
1448 * @returns {{cleaned: string, selected: object|null}} Reply without the JSON line, and the matched entry.
1449 */
1450function extractReferenceSelection(reply, candidates) {
1451 const pattern = /\{\s*"?reference_image"?\s*:\s*"([^"]*)"\s*\}/gi;
1452 let match;
1453 let lastTag = null;
1454 while ((match = pattern.exec(reply)) !== null) {
1455 lastTag = match[1];
1456 }
1457 const cleaned = reply.replace(/\{\s*"?reference_image"?\s*:\s*"[^"]*"\s*\}/gi, ' ');
1458 return { cleaned, selected: lastTag ? matchReferenceTag(lastTag, candidates) : null };
1459}
1460
1461/**
1462 * Asks the image-prompt LLM to pick the best-fitting reference image for a scene
1463 * in a dedicated (second) request.
1464 * @param {string} prompt The final image prompt describing the scene.
1465 * @param {{tag: string, description: string}[]} candidates Reference images to choose from.
1466 * @returns {Promise<object|null>} The matched entry, or null.
1467 */
1468async function selectReferenceImageWithLlm(prompt, candidates) {
1469 const list = candidates.map(x => `- "${x.tag}": ${x.description || 'no description'}`).join('\n');
1470 const quietPrompt = [
1471 'Pause your roleplay. An image is being generated for the current scene from this prompt:',
1472 prompt,
1473 '',
1474 'Pick the reference image whose description best fits that scene:',
1475 list,
1476 '',
1477 'Reply with ONLY the tag of the chosen reference image and nothing else.',
1478 ].join('\n');
1479 const profileId = extension_settings.sd.prompt_generation_profile;
1480 const reply = profileId
1481 ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt }))
1482 : await generateQuietPrompt({ quietPrompt });
1483 return matchReferenceTag(String(reply ?? '').trim(), candidates);
1484}
1485
1486/**
1487 * Resolves which reference image the current ComfyUI generation should use.
1488 * Prefers the selection made together with the image prompt; falls back to a
1489 * dedicated LLM call, and finally to the first library image.
1490 * @param {string} prompt The image prompt describing the scene.
1491 * @returns {Promise<object|null>} The reference image to use, or null when the feature is off/empty.
1492 */
1493async function resolveReferenceImageForGeneration(prompt) {
1494 if (!extension_settings.sd.ref_images_enabled) {
1495 return null;
1496 }
1497 const images = getValidRefImages();
1498 if (images.length === 0) {
1499 return null;
1500 }
1501 if (pendingReferenceImage && images.some(x => x.path === pendingReferenceImage.path)) {
1502 return pendingReferenceImage;
1503 }
1504 if (images.length === 1) {
1505 pendingReferenceImage = images[0];
1506 return pendingReferenceImage;
1507 }
1508 try {
1509 pendingReferenceImage = await selectReferenceImageWithLlm(prompt, images) ?? images[0];
1510 } catch (error) {
1511 console.error('SD: reference image selection failed, using the first library image', error);
1512 pendingReferenceImage = images[0];
1513 }
1514 return pendingReferenceImage;
1515}
1516
1517/**
1518 * Loads a reference image and returns it as a raw base64 string (no data URL header).
1519 * @param {{path: string}} refImage Reference image entry.
1520 * @returns {Promise<string|null>} Base64 image data, or null on failure.
1521 */
1522async function fetchReferenceImageBase64(refImage) {
1523 try {
1524 const response = await fetch(refImage.path);
1525 if (!response.ok) {
1526 throw new Error(`HTTP ${response.status}`);
1527 }
1528 const blob = await response.blob();
1529 const dataUrl = await getBase64Async(blob);
1530 return String(dataUrl).split(',')[1] ?? null;
1531 } catch (error) {
1532 console.error('SD: could not load reference image', refImage.path, error);
1533 return null;
1534 }
1535}
1536
1537// #endregion
1538
1539// #region RunPod lazy pod
1540
1541/** Poll cadence for the pod status indicator (faster while it is starting). */
1542const RUNPOD_POLL_IDLE_MS = 30000;
1543const RUNPOD_POLL_BUSY_MS = 5000;
1544/** Frontend heartbeat cadence. The proxy tolerates browser timer pauses for five minutes. */
1545const RUNPOD_HEARTBEAT_MS = 20000;
1546
1547let runpodStatusTimer = null;
1548let runpodHeartbeatTimer = null;
1549let runpodLastPhase = 'red';
1550
1551function getRunpodLazyUrl() {
1552 return String(extension_settings.sd.runpod_lazy_url ?? '').trim().replace(/\/$/, '');
1553}
1554
1555/**
1556 * Reads the lazy proxy status without starting or warming a pod.
1557 * @param {number} timeout Request timeout in milliseconds.
1558 * @returns {Promise<object>} Parsed /lazy/status response.
1559 */
1560async function getRunpodStatus(timeout = 8000) {
1561 const url = getRunpodLazyUrl();
1562 if (!url) {
1563 throw new Error('RunPod lazy proxy URL is not configured.');
1564 }
1565 const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(timeout) });
1566 if (!result.ok) {
1567 throw new Error(`RunPod status returned ${result.status}.`);
1568 }
1569 return result.json();
1570}
1571
1572/**
1573 * Checks whether the lazy proxy already has a ready pod.
1574 * @param {number} timeout Request timeout in milliseconds.
1575 * @returns {Promise<boolean>} True only when /lazy/status reports green.
1576 */
1577async function isRunpodReady(timeout = 8000) {
1578 try {
1579 return (await getRunpodStatus(timeout))?.state === 'green';
1580 } catch {
1581 return false;
1582 }
1583}
1584
1585/**
1586 * Updates the status dot + text from a /lazy/status response (or an error).
1587 * @param {object|null} status Parsed status JSON, or null when unreachable.
1588 */
1589function renderRunpodStatus(status) {
1590 const dot = document.getElementById('sd_runpod_dot');
1591 const text = document.getElementById('sd_runpod_status_text');
1592 if (!dot || !text) {
1593 return;
1594 }
1595 const phase = status?.state ?? 'red';
1596 runpodLastPhase = phase;
1597 const phaseClass = `sd_runpod_${['red', 'orange', 'green'].includes(phase) ? phase : 'red'}`;
1598 for (const el of [dot, document.getElementById('sd_runpod_bar_dot')].filter(Boolean)) {
1599 el.classList.remove('sd_runpod_red', 'sd_runpod_orange', 'sd_runpod_green');
1600 el.classList.add(phaseClass);
1601 }
1602 const openLink = document.getElementById('sd_runpod_open');
1603 if (openLink) {
1604 const showLink = phase === 'green' && status?.url;
1605 openLink.style.display = showLink ? '' : 'none';
1606 if (showLink) {
1607 openLink.dataset.url = status.url;
1608 }
1609 }
1610 if (!status) {
1611 text.textContent = 'proxy unreachable';
1612 } else if (phase === 'green') {
1613 const left = status.idle_seconds_left ? ` (idle stop in ${Math.round(status.idle_seconds_left / 60)}m)` : '';
1614 text.textContent = `ready on ${status.gpu ?? 'GPU'}${left}`;
1615 } else if (phase === 'orange') {
1616 text.textContent = `starting (models: ${status.model || (status.active ?? []).join(' + ') || '?'})…`;
1617 } else {
1618 text.textContent = 'off';
1619 }
1620 const barDot = document.getElementById('sd_runpod_bar_dot');
1621 if (barDot) {
1622 barDot.title = `RunPod pod: ${text.textContent} — click to ${phase === 'red' ? 'warm up' : 'shut down'}`;
1623 }
1624 return phase;
1625}
1626
1627/** Adds the clickable pod-status dot to the chat bar (next to other extension icons). */
1628function ensureRunpodBarDot() {
1629 if (document.getElementById('sd_runpod_bar_dot')) {
1630 return;
1631 }
1632 const anchor = document.getElementById('leftSendForm');
1633 if (!anchor) {
1634 return;
1635 }
1636 const dot = document.createElement('div');
1637 dot.id = 'sd_runpod_bar_dot';
1638 dot.classList.add('sd_runpod_dot', 'sd_runpod_bar_dot', 'sd_runpod_red', 'interactable');
1639 dot.title = 'RunPod pod';
1640 dot.tabIndex = 0;
1641 dot.addEventListener('click', () => runpodControl(runpodLastPhase === 'red' ? 'warmup' : 'shutdown'));
1642 anchor.appendChild(dot);
1643 dot.style.display = getRunpodLazyUrl() ? '' : 'none';
1644}
1645
1646let runpodPollFailures = 0;
1647
1648async function pollRunpodStatus() {
1649 const url = getRunpodLazyUrl();
1650 if (!url) {
1651 return;
1652 }
1653 let phase = runpodLastPhase;
1654 try {
1655 runpodPollFailures = 0;
1656 phase = renderRunpodStatus(await getRunpodStatus());
1657 } catch {
1658 // A single slow/failed poll (e.g. while the proxy is provisioning a pod)
1659 // must not flip the dot to red; only sustained unreachability does.
1660 runpodPollFailures++;
1661 if (runpodPollFailures >= 3) {
1662 phase = renderRunpodStatus(null);
1663 }
1664 }
1665 clearTimeout(runpodStatusTimer);
1666 runpodStatusTimer = setTimeout(pollRunpodStatus, phase === 'orange' || runpodPollFailures ? RUNPOD_POLL_BUSY_MS : RUNPOD_POLL_IDLE_MS);
1667}
1668
1669async function runpodControl(action) {
1670 const url = getRunpodLazyUrl();
1671 if (!url) {
1672 return;
1673 }
1674 try {
1675 if (action === 'warmup' && !(await pushRunpodCatalog())) {
1676 throw new Error('Could not sync the RunPod model catalog. The pod was not started.');
1677 }
1678 const result = await fetch(`${url}/lazy/${action}`, { method: 'POST', signal: AbortSignal.timeout(10000) });
1679 if (!result.ok) {
1680 throw new Error(`RunPod ${action} returned ${result.status}.`);
1681 }
1682 renderRunpodStatus(await result.json());
1683 if (action === 'warmup') {
1684 toastr.info(t`Pod warmup requested. Models will pre-download; the dot turns green when ready.`, t`Image Generation`);
1685 }
1686 } catch (error) {
1687 toastr.error(String(error), t`Image Generation`);
1688 }
1689 clearTimeout(runpodStatusTimer);
1690 runpodStatusTimer = setTimeout(pollRunpodStatus, RUNPOD_POLL_BUSY_MS);
1691}
1692
1693function runpodHeartbeat() {
1694 const url = getRunpodLazyUrl();
1695 if (!url) {
1696 return;
1697 }
1698 // This only renews the proxy's frontend lease and never starts a pod. The
1699 // proxy owns the stable upstream keepalive cadence so browser timer
1700 // throttling cannot directly skew it.
1701 fetch(`${url}/lazy/ping`, { method: 'POST', signal: AbortSignal.timeout(5000) }).catch(() => { });
1702}
1703
1704/** (Re)starts the status polling and frontend-heartbeat loops. */
1705function setupRunpodLoops() {
1706 clearTimeout(runpodStatusTimer);
1707 clearInterval(runpodHeartbeatTimer);
1708 ensureRunpodBarDot();
1709 const barDot = document.getElementById('sd_runpod_bar_dot');
1710 if (barDot) {
1711 barDot.style.display = getRunpodLazyUrl() ? '' : 'none';
1712 }
1713 if (!getRunpodLazyUrl()) {
1714 renderRunpodStatus(null);
1715 return;
1716 }
1717 pollRunpodStatus();
1718 runpodHeartbeat();
1719 runpodHeartbeatTimer = setInterval(runpodHeartbeat, RUNPOD_HEARTBEAT_MS);
1720}
1721
1722function onRunpodLazyUrlInput() {
1723 extension_settings.sd.runpod_lazy_url = String($(this).val() ?? '').trim();
1724 saveSettingsDebounced();
1725 setupRunpodLoops();
1726}
1727
1728/**
1729 * Whether a comfy URL points at the runpod-lazy proxy.
1730 * @param {string} url ComfyUI URL to test.
1731 * @returns {boolean} True when it equals the configured proxy URL.
1732 */
1733function isRunpodProxyUrl(url) {
1734 const lazy = getRunpodLazyUrl();
1735 return !!lazy && String(url ?? '').trim().replace(/\/$/, '') === lazy;
1736}
1737
1738/**
1739 * Returns the configured RunPod model catalog entries that have a filename.
1740 * @returns {{name: string, value: string, downloads: string}[]} Catalog entries.
1741 */
1742function getRunpodCatalog() {
1743 const models = Array.isArray(extension_settings.sd.runpod_models) ? extension_settings.sd.runpod_models : [];
1744 return models.filter(m => m && String(m.value ?? '').trim());
1745}
1746
1747/** Catalog entries that go in the Model dropdown (kind !== 'lora'). */
1748function getRunpodModelEntries() {
1749 return getRunpodCatalog().filter(m => m.kind !== 'lora');
1750}
1751
1752/**
1753 * Parses a catalog entry's download lines ("dest-subpath url" per line).
1754 * @param {string} text Multiline downloads definition.
1755 * @returns {{dest: string, url: string}[]} Parsed file list.
1756 */
1757function parseRunpodFiles(text) {
1758 return String(text ?? '').split('\n')
1759 .map(line => line.trim())
1760 .filter(Boolean)
1761 .map(line => {
1762 const space = line.indexOf(' ');
1763 return space > 0 ? { dest: line.slice(0, space).trim(), url: line.slice(space + 1).trim() } : null;
1764 })
1765 .filter(x => x && x.dest && x.url);
1766}
1767
1768/**
1769 * The model+lora filenames saved for whichever config uses the proxy: the live
1770 * settings if they point at it, else the chain entry that does.
1771 * @returns {string[]} Active catalog values (model and/or lora).
1772 */
1773function getRunpodActiveModels() {
1774 let config = null;
1775 // Only use live settings when the current source is actually ComfyUI AND
1776 // its model is in the catalog. If the user is on Grok/OpenRouter but
1777 // comfy_url points at the proxy, the fallback chain's ComfyUI presets are
1778 // what matter, not the live source which may not even be image-gen capable.
1779 if (extension_settings.sd.source === 'comfy' && isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
1780 const known = new Set(getRunpodCatalog().map(m => m.value));
1781 if (known.has(extension_settings.sd.model)) {
1782 config = extension_settings.sd;
1783 }
1784 }
1785 if (!config) {
1786 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
1787 // Find the first ComfyUI preset in the chain, not just any preset with a matching URL
1788 config = chain.find(e => e?.preset?.source === 'comfy' && isRunpodProxyUrl(e?.preset?.comfy_url))?.preset ?? null;
1789 }
1790 if (!config) {
1791 return [];
1792 }
1793 const known = new Set(getRunpodCatalog().map(m => m.value));
1794 return [config.model, config.lora].filter(v => v && known.has(v));
1795}
1796
1797/**
1798 * Pushes the model catalog + active selection to the proxy.
1799 * Catalog updates only store configuration; only /lazy/warmup may provision.
1800 * @returns {Promise<boolean>} Whether the proxy accepted the catalog.
1801 */
1802async function pushRunpodCatalog() {
1803 const url = getRunpodLazyUrl();
1804 if (!url) {
1805 return false;
1806 }
1807 const models = getRunpodCatalog().map(m => ({
1808 name: m.name || m.value,
1809 value: m.value,
1810 kind: m.kind === 'lora' ? 'lora' : 'model',
1811 files: parseRunpodFiles(m.downloads),
1812 }));
1813 try {
1814 const result = await fetch(`${url}/lazy/catalog`, {
1815 method: 'POST',
1816 headers: { 'Content-Type': 'application/json' },
1817 signal: AbortSignal.timeout(8000),
1818 body: JSON.stringify({ models, active: getRunpodActiveModels() }),
1819 });
1820 if (!result.ok) {
1821 throw new Error(`catalog returned ${result.status}`);
1822 }
1823 return true;
1824 } catch (error) {
1825 console.warn('SD: runpod catalog push failed', error);
1826 return false;
1827 }
1828}
1829
1830/**
1831 * Rebuilds the RunPod model catalog editor in the settings UI.
1832 */
1833function renderRunpodModels() {
1834 const container = $('#sd_runpod_models_list');
1835 if (!container.length) {
1836 return;
1837 }
1838 container.empty();
1839 const models = Array.isArray(extension_settings.sd.runpod_models) ? extension_settings.sd.runpod_models : [];
1840 if (models.length === 0) {
1841 container.append($('<small></small>').text('No models configured.'));
1842 return;
1843 }
1844 models.forEach((model, index) => {
1845 const nameInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Display name' })
1846 .val(model.name || '')
1847 .on('change', function () { model.name = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
1848 const valueInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Filename (used by %model% / %lora%)' })
1849 .val(model.value || '')
1850 .on('change', function () { model.value = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
1851 const kindSelect = $('<select></select>').addClass('text_pole')
1852 .append($('<option></option>').val('model').text('model'))
1853 .append($('<option></option>').val('lora').text('lora'))
1854 .val(model.kind === 'lora' ? 'lora' : 'model')
1855 .on('change', function () { model.kind = String($(this).val()); saveSettingsDebounced(); pushRunpodCatalog(); });
1856 const deleteButton = $('<div></div>').addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1857 .attr('title', 'Remove model')
1858 .on('click', () => { models.splice(index, 1); saveSettingsDebounced(); renderRunpodModels(); pushRunpodCatalog(); });
1859 const downloadsInput = $('<textarea></textarea>').addClass('text_pole textarea_compact')
1860 .attr({ rows: 2, placeholder: 'One file per line: <models-subpath> <url>\ne.g. unet/model.gguf https://huggingface.co/...' })
1861 .val(model.downloads || '')
1862 .on('change', function () { model.downloads = String($(this).val() ?? ''); saveSettingsDebounced(); pushRunpodCatalog(); });
1863 container.append(
1864 $('<div></div>').addClass('flexFlowColumn marginTopBot5')
1865 .append($('<div></div>').addClass('flex-container alignItemsCenter').append(nameInput).append(valueInput).append(kindSelect).append(deleteButton))
1866 .append(downloadsInput));
1867 });
1868}
1869
1870function onRunpodModelsAddClick() {
1871 if (!Array.isArray(extension_settings.sd.runpod_models)) {
1872 extension_settings.sd.runpod_models = [];
1873 }
1874 extension_settings.sd.runpod_models.push({ name: '', value: '', downloads: '' });
1875 saveSettingsDebounced();
1876 renderRunpodModels();
1877}
1878
1879// #endregion
1880
1881/**
1882 * Rebuilds the custom wand entries list in the settings UI.
1883 */
1884function renderCustomEntriesList() {
1885 const container = $('#sd_custom_entries_list');
1886 if (!container.length) {
1887 return;
1888 }
1889
1890 container.empty();
1891
1892 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1893
1894 if (entries.length === 0) {
1895 const empty = $('<small></small>')
1896 .attr('data-i18n', 'No custom entries yet.')
1897 .text('No custom entries yet.');
1898 container.append(empty);
1899 return;
1900 }
1901
1902 for (const entry of entries) {
1903 const preview = String(entry.prompt || '').replace(/\s+/g, ' ').trim();
1904 const truncated = preview.length > 80 ? preview.slice(0, 80) + '…' : preview;
1905
1906 const titleEl = $('<div></div>').addClass('sd_custom_entry_title').text(entry.title);
1907 const previewEl = $('<small></small>').addClass('sd_custom_entry_preview').text(truncated);
1908 const textBlock = $('<div></div>').addClass('flex1 flexFlowColumn').append(titleEl).append(previewEl);
1909
1910 const editButton = $('<div></div>')
1911 .addClass('menu_button menu_button_icon fa-solid fa-pencil')
1912 .attr('data-entry-id', entry.id)
1913 .attr('data-action', 'edit')
1914 .attr('title', 'Edit')
1915 .attr('data-i18n', '[title]Edit');
1916 const deleteButton = $('<div></div>')
1917 .addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1918 .attr('data-entry-id', entry.id)
1919 .attr('data-action', 'delete')
1920 .attr('title', 'Delete')
1921 .attr('data-i18n', '[title]Delete');
1922
1923 const row = $('<div></div>')
1924 .addClass('flex-container alignItemsCenter marginTopBot5 sd_custom_entry_row')
1925 .append(textBlock)
1926 .append(editButton)
1927 .append(deleteButton);
1928
1929 container.append(row);
1930 }
1931}
1932
1933/**
1934 * Generates a unique id for a custom entry.
1935 * @returns {string} A unique identifier.
1936 */
1937function getUniqueCustomEntryId() {
1938 return crypto.randomUUID?.() ?? ('ce_' + Date.now() + '_' + Math.floor(Math.random() * 1e6));
1939}
1940
1941/**
1942 * Opens a popup to create/edit a custom entry and returns the entered values.
1943 * @param {string} title Initial title value.
1944 * @param {string} prompt Initial prompt value.
1945 * @returns {Promise<{title: string, prompt: string} | null>} Entered values, or null if cancelled.
1946 */
1947async function showCustomEntryPopup(title, prompt) {
1948 const form = $('<div></div>').addClass('flex-container flexFlowColumn');
1949 const titleLabel = $('<label></label>').attr('data-i18n', 'Title').text('Title');
1950 const titleInput = $('<input>')
1951 .addClass('text_pole')
1952 .attr('type', 'text')
1953 .attr('id', 'sd_custom_entry_title_input')
1954 .val(title || '');
1955 const promptLabel = $('<label></label>').attr('data-i18n', 'Prompt').text('Prompt');
1956 const promptInput = $('<textarea></textarea>')
1957 .addClass('text_pole textarea_compact')
1958 .attr('id', 'sd_custom_entry_prompt_input')
1959 .attr('rows', 5)
1960 .val(prompt || '');
1961
1962 form.append(titleLabel).append(titleInput).append(promptLabel).append(promptInput);
1963
1964 const popup = new Popup(form, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
1965 const result = await popup.show();
1966
1967 if (!result) {
1968 return null;
1969 }
1970
1971 return {
1972 title: String(titleInput.val() ?? '').trim(),
1973 prompt: String(promptInput.val() ?? '').trim(),
1974 };
1975}
1976
1977async function onAddCustomEntryClick() {
1978 const values = await showCustomEntryPopup('', '');
1979
1980 if (!values) {
1981 return;
1982 }
1983
1984 if (!values.title || !values.prompt) {
1985 toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
1986 return;
1987 }
1988
1989 if (!Array.isArray(extension_settings.sd.custom_entries)) {
1990 extension_settings.sd.custom_entries = [];
1991 }
1992
1993 extension_settings.sd.custom_entries.push({
1994 id: getUniqueCustomEntryId(),
1995 title: values.title,
1996 prompt: values.prompt,
1997 });
1998
1999 saveSettingsDebounced();
2000 renderCustomEntriesList();
2001 renderCustomDropdownEntries();
2002}
2003
2004async function onEditCustomEntryClick(id) {
2005 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
2006 const entry = entries.find(e => e.id === id);
2007
2008 if (!entry) {
2009 return;
2010 }
2011
2012 const values = await showCustomEntryPopup(entry.title, entry.prompt);
2013
2014 if (!values) {
2015 return;
2016 }
2017
2018 if (!values.title || !values.prompt) {
2019 toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
2020 return;
2021 }
2022
2023 entry.title = values.title;
2024 entry.prompt = values.prompt;
2025
2026 saveSettingsDebounced();
2027 renderCustomEntriesList();
2028 renderCustomDropdownEntries();
2029}
2030
2031async function onDeleteCustomEntryClick(id) {
2032 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
2033 const index = entries.findIndex(e => e.id === id);
2034
2035 if (index === -1) {
2036 return;
2037 }
2038
2039 const confirmed = await callGenericPopup(t`Are you sure you want to delete the entry "${entries[index].title}"?`, POPUP_TYPE.CONFIRM, '', { okButton: t`Delete`, cancelButton: t`Cancel` });
2040
2041 if (!confirmed) {
2042 return;
2043 }
2044
2045 entries.splice(index, 1);
2046 saveSettingsDebounced();
2047 renderCustomEntriesList();
2048 renderCustomDropdownEntries();
2049}
2050
2051/**
2052 * Modifies prompt based on user inputs.
2053 * @param {string} prompt Prompt to refine
2054 * @param {object} [args] Additional arguments for refinement
2055 * @param {string} [args.negative] Negative prompt to prefill
2056 * @param {string} [args.resolution] Saved resolution to offer as a checkbox option
2057 * @returns {Promise<string>} Refined prompt
2058 */
2059async function refinePrompt(prompt, args = null) {
2060 if (extension_settings.sd.refine_mode) {
2061 /** @type {import('../../popup.js').CustomPopupInput[]} */
2062 const customInputs = [];
2063
2064 if (args?.negative) {
2065 customInputs.push({
2066 id: 'sd_refine_negative',
2067 label: t`Negative prompt (optional)`,
2068 type: 'textarea',
2069 rows: 4,
2070 defaultState: String(args.negative || ''),
2071 });
2072 }
2073
2074 if (args?.resolution) {
2075 customInputs.push({
2076 id: 'sd_use_saved_resolution',
2077 label: t`Use saved resolution (${args.resolution})`,
2078 type: 'checkbox',
2079 defaultState: true,
2080 });
2081 }
2082
2083 const refinedPrompt = await Popup.show.input(
2084 t`Review and edit the prompt:`,
2085 t`Press "Cancel" to abort the image generation.`,
2086 prompt.trim(),
2087 {
2088 rows: 8,
2089 okButton: t`Continue`,
2090 cancelButton: t`Cancel`,
2091 customInputs,
2092 onClose: (popup) => {
2093 if (!popup.result || !(popup.inputResults instanceof Map) || !args) {
2094 return;
2095 }
2096
2097 const negativeInput = popup.inputResults.get('sd_refine_negative');
2098 const useSavedResolution = popup.inputResults.get('sd_use_saved_resolution');
2099
2100 if (negativeInput) {
2101 args.negative = negativeInput.toString().trim();
2102 }
2103 if (!useSavedResolution) {
2104 args.resolution = null;
2105 }
2106 },
2107 });
2108
2109 if (refinedPrompt) {
2110 return String(refinedPrompt);
2111 } else {
2112 throw new Error('Generation aborted by user.');
2113 }
2114 }
2115
2116 return prompt;
2117}
2118
2119async function onChatChanged() {
2120 if (this_chid === undefined || selected_group) {
2121 $('#sd_character_prompt_block').hide();
2122 return;
2123 }
2124
2125 $('#sd_character_prompt_block').show();
2126
2127 const key = getCharaFilename(this_chid);
2128 let characterPrompt = key ? (extension_settings.sd.character_prompts[key] || '') : '';
2129 let negativePrompt = key ? (extension_settings.sd.character_negative_prompts[key] || '') : '';
2130
2131 const context = getContext();
2132 const sharedPromptData = context?.characters[this_chid]?.data?.extensions?.sd_character_prompt;
2133 const hasSharedData = sharedPromptData && typeof sharedPromptData === 'object';
2134
2135 if (typeof sharedPromptData?.positive === 'string' && !characterPrompt && sharedPromptData.positive) {
2136 characterPrompt = sharedPromptData.positive;
2137 extension_settings.sd.character_prompts[key] = characterPrompt;
2138 }
2139 if (typeof sharedPromptData?.negative === 'string' && !negativePrompt && sharedPromptData.negative) {
2140 negativePrompt = sharedPromptData.negative;
2141 extension_settings.sd.character_negative_prompts[key] = negativePrompt;
2142 }
2143
2144 $('#sd_character_prompt').val(characterPrompt);
2145 $('#sd_character_negative_prompt').val(negativePrompt);
2146 $('#sd_character_prompt_share').prop('checked', hasSharedData);
2147 await adjustElementScrollHeight();
2148}
2149
2150async function adjustElementScrollHeight() {
2151 if (CSS.supports('field-sizing', 'content') || !$('.sd_settings').is(':visible')) {
2152 return;
2153 }
2154
2155 await resetScrollHeight($('#sd_prompt_prefix'));
2156 await resetScrollHeight($('#sd_negative_prompt'));
2157 await resetScrollHeight($('#sd_character_prompt'));
2158 await resetScrollHeight($('#sd_character_negative_prompt'));
2159}
2160
2161async function onCharacterPromptInput() {
2162 const key = getCharaFilename(this_chid);
2163 extension_settings.sd.character_prompts[key] = $('#sd_character_prompt').val();
2164 saveSettingsDebounced();
2165 writePromptFieldsDebounced(this_chid);
2166 if (CSS.supports('field-sizing', 'content')) return;
2167 await resetScrollHeight($(this));
2168}
2169
2170async function onCharacterNegativePromptInput() {
2171 const key = getCharaFilename(this_chid);
2172 extension_settings.sd.character_negative_prompts[key] = $('#sd_character_negative_prompt').val();
2173 saveSettingsDebounced();
2174 writePromptFieldsDebounced(this_chid);
2175 if (CSS.supports('field-sizing', 'content')) return;
2176 await resetScrollHeight($(this));
2177}
2178
2179function getCharacterPrefix() {
2180 if (this_chid === undefined || selected_group) {
2181 return '';
2182 }
2183
2184 const key = getCharaFilename(this_chid);
2185
2186 if (key) {
2187 return extension_settings.sd.character_prompts[key] || '';
2188 }
2189
2190 return '';
2191}
2192
2193function getCharacterNegativePrefix() {
2194 if (this_chid === undefined || selected_group) {
2195 return '';
2196 }
2197
2198 const key = getCharaFilename(this_chid);
2199
2200 if (key) {
2201 return extension_settings.sd.character_negative_prompts[key] || '';
2202 }
2203
2204 return '';
2205}
2206
2207/**
2208 * Combines two prompt prefixes into one.
2209 * @param {string} str1 Base string
2210 * @param {string} str2 Secondary string
2211 * @param {string} macro Macro to replace with the secondary string
2212 * @returns {string} Combined string with a comma between them
2213 */
2214function combinePrefixes(str1, str2, macro = '') {
2215 // Remove leading/trailing white spaces and commas from the strings
2216 const process = (s) => s.trim().replace(/^,|,$/g, '').trim();
2217
2218 if (!str2) {
2219 return str1;
2220 }
2221
2222 str1 = process(str1);
2223 str2 = process(str2);
2224
2225 // Combine the strings with a comma between them)
2226 const result = macro && str1.includes(macro) ? str1.replace(macro, str2) : `${str1}, ${str2},`;
2227 return process(result);
2228}
2229
2230function onRefineModeInput() {
2231 extension_settings.sd.refine_mode = !!$('#sd_refine_mode').prop('checked');
2232 saveSettingsDebounced();
2233}
2234
2235function onFreeExtendInput() {
2236 extension_settings.sd.free_extend = !!$('#sd_free_extend').prop('checked');
2237 saveSettingsDebounced();
2238}
2239
2240function onWandVisibleInput() {
2241 extension_settings.sd.wand_visible = !!$('#sd_wand_visible').prop('checked');
2242 saveSettingsDebounced();
2243}
2244
2245function onCommandVisibleInput() {
2246 extension_settings.sd.command_visible = !!$('#sd_command_visible').prop('checked');
2247 saveSettingsDebounced();
2248}
2249
2250function onInteractiveVisibleInput() {
2251 extension_settings.sd.interactive_visible = !!$('#sd_interactive_visible').prop('checked');
2252 saveSettingsDebounced();
2253}
2254
2255function onToolVisibleInput() {
2256 extension_settings.sd.tool_visible = !!$('#sd_tool_visible').prop('checked');
2257 saveSettingsDebounced();
2258}
2259
2260function onClipSkipInput() {
2261 extension_settings.sd.clip_skip = Number($('#sd_clip_skip').val());
2262 $('#sd_clip_skip_value').val(extension_settings.sd.clip_skip);
2263 saveSettingsDebounced();
2264}
2265
2266function onSeedInput() {
2267 extension_settings.sd.seed = Number($('#sd_seed').val());
2268 saveSettingsDebounced();
2269}
2270
2271function onScaleInput() {
2272 extension_settings.sd.scale = Number($('#sd_scale').val());
2273 $('#sd_scale_value').val(extension_settings.sd.scale.toFixed(1));
2274 saveSettingsDebounced();
2275}
2276
2277function onStepsInput() {
2278 extension_settings.sd.steps = Number($('#sd_steps').val());
2279 $('#sd_steps_value').val(extension_settings.sd.steps);
2280 saveSettingsDebounced();
2281}
2282
2283async function onPromptPrefixInput() {
2284 extension_settings.sd.prompt_prefix = $('#sd_prompt_prefix').val();
2285 saveSettingsDebounced();
2286 if (CSS.supports('field-sizing', 'content')) return;
2287 await resetScrollHeight($(this));
2288}
2289
2290async function onNegativePromptInput() {
2291 extension_settings.sd.negative_prompt = $('#sd_negative_prompt').val();
2292 saveSettingsDebounced();
2293 if (CSS.supports('field-sizing', 'content')) return;
2294 await resetScrollHeight($(this));
2295}
2296
2297function onSamplerChange() {
2298 extension_settings.sd.sampler = $('#sd_sampler').find(':selected').val();
2299 saveSettingsDebounced();
2300}
2301
2302function onADetailerFaceChange() {
2303 extension_settings.sd.adetailer_face = !!$('#sd_adetailer_face').prop('checked');
2304 saveSettingsDebounced();
2305}
2306
2307const resolutionOptions = {
2308 sd_res_512x512: { width: 512, height: 512, name: translate('512x512 (1:1, icons, profile pictures)', 'sd_res_512x512') },
2309 sd_res_600x600: { width: 600, height: 600, name: translate('600x600 (1:1, icons, profile pictures)', 'sd_res_600x600') },
2310 sd_res_512x768: { width: 512, height: 768, name: translate('512x768 (2:3, vertical character card)', 'sd_res_512x768') },
2311 sd_res_768x512: { width: 768, height: 512, name: translate('768x512 (3:2, horizontal 35-mm movie film)', 'sd_res_768x512') },
2312 sd_res_960x540: { width: 960, height: 540, name: translate('960x540 (16:9, horizontal wallpaper)', 'sd_res_960x540') },
2313 sd_res_540x960: { width: 540, height: 960, name: translate('540x960 (9:16, vertical wallpaper)', 'sd_res_540x960') },
2314 sd_res_1920x1088: { width: 1920, height: 1088, name: translate('1920x1088 (16:9, 1080p, horizontal wallpaper)', 'sd_res_1920x1088') },
2315 sd_res_1088x1920: { width: 1088, height: 1920, name: translate('1088x1920 (9:16, 1080p, vertical wallpaper)', 'sd_res_1088x1920') },
2316 sd_res_1280x720: { width: 1280, height: 720, name: translate('1280x720 (16:9, 720p, horizontal wallpaper)', 'sd_res_1280x720') },
2317 sd_res_720x1280: { width: 720, height: 1280, name: translate('720x1280 (9:16, 720p, vertical wallpaper)', 'sd_res_720x1280') },
2318 sd_res_1024x1024: { width: 1024, height: 1024, name: '1024x1024 (1:1, SDXL)' },
2319 sd_res_1152x896: { width: 1152, height: 896, name: '1152x896 (9:7, SDXL)' },
2320 sd_res_896x1152: { width: 896, height: 1152, name: '896x1152 (7:9, SDXL)' },
2321 sd_res_1216x832: { width: 1216, height: 832, name: '1216x832 (19:13, SDXL)' },
2322 sd_res_832x1216: { width: 832, height: 1216, name: '832x1216 (13:19, SDXL)' },
2323 sd_res_1344x768: { width: 1344, height: 768, name: '1344x768 (4:3, SDXL)' },
2324 sd_res_768x1344: { width: 768, height: 1344, name: '768x1344 (3:4, SDXL)' },
2325 sd_res_1536x640: { width: 1536, height: 640, name: '1536x640 (24:10, SDXL)' },
2326 sd_res_640x1536: { width: 640, height: 1536, name: '640x1536 (10:24, SDXL)' },
2327 sd_res_1536x1024: { width: 1536, height: 1024, name: '1536x1024 (3:2, ChatGPT)' },
2328 sd_res_1024x1536: { width: 1024, height: 1536, name: '1024x1536 (2:3, ChatGPT)' },
2329 sd_res_1024x1792: { width: 1024, height: 1792, name: '1024x1792 (4:7, DALL-E)' },
2330 sd_res_1792x1024: { width: 1792, height: 1024, name: '1792x1024 (7:4, DALL-E)' },
2331 sd_res_1280x1280: { width: 1280, height: 1280, name: '1280x1280 (1:1, Z.AI)' },
2332 sd_res_1568x1056: { width: 1568, height: 1056, name: '1568x1056 (3:2, Z.AI)' },
2333 sd_res_1056x1568: { width: 1056, height: 1568, name: '1056x1568 (2:3, Z.AI)' },
2334 sd_res_1472x1088: { width: 1472, height: 1088, name: '1472x1088 (4:3, Z.AI)' },
2335 sd_res_1088x1472: { width: 1088, height: 1472, name: '1088x1472 (3:4, Z.AI)' },
2336 sd_res_1728x960: { width: 1728, height: 960, name: '1728x960 (16:9, Z.AI)' },
2337 sd_res_960x1728: { width: 960, height: 1728, name: '960x1728 (9:16, Z.AI)' },
2338};
2339
2340function onResolutionChange() {
2341 const selectedOption = $('#sd_resolution').val();
2342 const selectedResolution = resolutionOptions[selectedOption];
2343
2344 if (!selectedResolution) {
2345 console.warn(`Could not find resolution option for ${selectedOption}`);
2346 return;
2347 }
2348
2349 $('#sd_height').val(selectedResolution.height).trigger('input');
2350 $('#sd_width').val(selectedResolution.width).trigger('input');
2351}
2352
2353function onSchedulerChange() {
2354 extension_settings.sd.scheduler = $('#sd_scheduler').find(':selected').val();
2355 saveSettingsDebounced();
2356}
2357
2358function onWidthInput() {
2359 extension_settings.sd.width = Number($('#sd_width').val());
2360 $('#sd_width_value').val(extension_settings.sd.width);
2361 saveSettingsDebounced();
2362}
2363
2364function onHeightInput() {
2365 extension_settings.sd.height = Number($('#sd_height').val());
2366 $('#sd_height_value').val(extension_settings.sd.height);
2367 saveSettingsDebounced();
2368}
2369
2370function onSwapDimensionsClick() {
2371 const w = extension_settings.sd.height;
2372 const h = extension_settings.sd.width;
2373 extension_settings.sd.width = w;
2374 extension_settings.sd.height = h;
2375 $('#sd_width').val(w).trigger('input');
2376 $('#sd_height').val(h).trigger('input');
2377 saveSettingsDebounced();
2378}
2379
2380async function onSourceChange() {
2381 extension_settings.sd.source = $('#sd_source').find(':selected').val();
2382 extension_settings.sd.model = null;
2383 extension_settings.sd.sampler = null;
2384 extension_settings.sd.scheduler = null;
2385 extension_settings.sd.vae = null;
2386 toggleSourceControls();
2387 saveSettingsDebounced();
2388 await loadSettingOptions();
2389}
2390
2391async function onComfyTypeChange() {
2392 extension_settings.sd.comfy_type = $('#sd_comfy_type').find(':selected').val();
2393 await onSourceChange();
2394}
2395
2396function onFunctionToolInput() {
2397 extension_settings.sd.function_tool = !!$(this).prop('checked');
2398 saveSettingsDebounced();
2399 registerFunctionTool();
2400}
2401
2402async function onOpenAiStyleSelect() {
2403 extension_settings.sd.openai_style = String($('#sd_openai_style').find(':selected').val());
2404 saveSettingsDebounced();
2405}
2406
2407async function onOpenAiQualitySelect() {
2408 extension_settings.sd.openai_quality = String($('#sd_openai_quality').find(':selected').val());
2409 saveSettingsDebounced();
2410}
2411
2412async function onOpenAiDurationSelect() {
2413 extension_settings.sd.openai_duration = String($('#sd_openai_duration').find(':selected').val());
2414 saveSettingsDebounced();
2415}
2416
2417async function onViewAnlasClick() {
2418 const result = await loadNovelSubscriptionData();
2419
2420 if (!result) {
2421 toastr.warning('Are you subscribed?', 'Could not load NovelAI subscription data');
2422 return;
2423 }
2424
2425 const anlas = getNovelAnlas();
2426 const unlimitedGeneration = getNovelUnlimitedImageGeneration();
2427
2428 toastr.info(`Free image generation: ${unlimitedGeneration ? 'Yes' : 'No'}`, `Anlas: ${anlas}`);
2429}
2430
2431function onNovelAnlasGuardInput() {
2432 extension_settings.sd.novel_anlas_guard = !!$('#sd_novel_anlas_guard').prop('checked');
2433 saveSettingsDebounced();
2434}
2435
2436function onNovelSmInput() {
2437 extension_settings.sd.novel_sm = !!$('#sd_novel_sm').prop('checked');
2438 saveSettingsDebounced();
2439
2440 if (!extension_settings.sd.novel_sm) {
2441 $('#sd_novel_sm_dyn').prop('checked', false).prop('disabled', true).trigger('input');
2442 } else {
2443 $('#sd_novel_sm_dyn').prop('disabled', false);
2444 }
2445}
2446
2447function onNovelSmDynInput() {
2448 extension_settings.sd.novel_sm_dyn = !!$('#sd_novel_sm_dyn').prop('checked');
2449 saveSettingsDebounced();
2450}
2451
2452function onNovelDecrisperInput() {
2453 extension_settings.sd.novel_decrisper = !!$('#sd_novel_decrisper').prop('checked');
2454 saveSettingsDebounced();
2455}
2456
2457function onNovelVarietyBoostInput() {
2458 extension_settings.sd.novel_variety_boost = !!$('#sd_novel_variety_boost').prop('checked');
2459 saveSettingsDebounced();
2460}
2461
2462function onPollinationsEnhanceInput() {
2463 extension_settings.sd.pollinations_enhance = !!$('#sd_pollinations_enhance').prop('checked');
2464 saveSettingsDebounced();
2465}
2466
2467function onHordeNsfwInput() {
2468 extension_settings.sd.horde_nsfw = !!$(this).prop('checked');
2469 saveSettingsDebounced();
2470}
2471
2472function onHordeKarrasInput() {
2473 extension_settings.sd.horde_karras = !!$(this).prop('checked');
2474 saveSettingsDebounced();
2475}
2476
2477function onHordeSanitizeInput() {
2478 extension_settings.sd.horde_sanitize = !!$(this).prop('checked');
2479 saveSettingsDebounced();
2480}
2481
2482function onRestoreFacesInput() {
2483 extension_settings.sd.restore_faces = !!$(this).prop('checked');
2484 saveSettingsDebounced();
2485}
2486
2487function onHighResFixInput() {
2488 extension_settings.sd.enable_hr = !!$(this).prop('checked');
2489 saveSettingsDebounced();
2490}
2491
2492function onAutoUrlInput() {
2493 extension_settings.sd.auto_url = $('#sd_auto_url').val();
2494 saveSettingsDebounced();
2495}
2496
2497function onAutoAuthInput() {
2498 extension_settings.sd.auto_auth = $('#sd_auto_auth').val();
2499 saveSettingsDebounced();
2500}
2501
2502function onSdcppUrlInput() {
2503 extension_settings.sd.sdcpp_url = $('#sd_sdcpp_url').val();
2504 saveSettingsDebounced();
2505}
2506
2507function onVladUrlInput() {
2508 extension_settings.sd.vlad_url = $('#sd_vlad_url').val();
2509 saveSettingsDebounced();
2510}
2511
2512function onVladAuthInput() {
2513 extension_settings.sd.vlad_auth = $('#sd_vlad_auth').val();
2514 saveSettingsDebounced();
2515}
2516
2517function onDrawthingsUrlInput() {
2518 extension_settings.sd.drawthings_url = $('#sd_drawthings_url').val();
2519 saveSettingsDebounced();
2520}
2521
2522function onDrawthingsAuthInput() {
2523 extension_settings.sd.drawthings_auth = $('#sd_drawthings_auth').val();
2524 saveSettingsDebounced();
2525}
2526
2527function onHrUpscalerChange() {
2528 extension_settings.sd.hr_upscaler = $('#sd_hr_upscaler').find(':selected').val();
2529 saveSettingsDebounced();
2530}
2531
2532function onHrScaleInput() {
2533 extension_settings.sd.hr_scale = Number($('#sd_hr_scale').val());
2534 $('#sd_hr_scale_value').val(extension_settings.sd.hr_scale.toFixed(1));
2535 saveSettingsDebounced();
2536}
2537
2538function onDenoisingStrengthInput() {
2539 extension_settings.sd.denoising_strength = Number($('#sd_denoising_strength').val());
2540 $('#sd_denoising_strength_value').val(extension_settings.sd.denoising_strength.toFixed(2));
2541 saveSettingsDebounced();
2542}
2543
2544function onLoraStrengthInput() {
2545 extension_settings.sd.lora_strength = Number($('#sd_lora_strength').val());
2546 $('#sd_lora_strength_value').val(extension_settings.sd.lora_strength.toFixed(2));
2547 saveSettingsDebounced();
2548}
2549
2550function onHrSecondPassStepsInput() {
2551 extension_settings.sd.hr_second_pass_steps = Number($('#sd_hr_second_pass_steps').val());
2552 $('#sd_hr_second_pass_steps_value').val(extension_settings.sd.hr_second_pass_steps);
2553 saveSettingsDebounced();
2554}
2555
2556function onComfyUrlInput() {
2557 extension_settings.sd.comfy_url = String($('#sd_comfy_url').val());
2558 saveSettingsDebounced();
2559}
2560
2561function onComfyRunPodUrlInput() {
2562 extension_settings.sd.comfy_runpod_url = String($('#sd_comfy_runpod_url').val());
2563 saveSettingsDebounced();
2564}
2565
2566function onHFModelInput() {
2567 extension_settings.sd.huggingface_model_id = $('#sd_huggingface_model_id').val();
2568 saveSettingsDebounced();
2569}
2570
2571/**
2572 * Stores the current model/lora choice for the active comfy workflow, so
2573 * switching back to this workflow restores it (per provider via presets).
2574 * @param {'model'|'lora'} key Which selection to remember.
2575 * @param {string} value Selected value.
2576 */
2577function rememberWorkflowPref(key, value) {
2578 if (extension_settings.sd.source !== sources.comfy || !extension_settings.sd.comfy_workflow) {
2579 return;
2580 }
2581 const prefs = extension_settings.sd.comfy_workflow_prefs;
2582 prefs[extension_settings.sd.comfy_workflow] = { ...(prefs[extension_settings.sd.comfy_workflow] ?? {}), [key]: value };
2583}
2584
2585async function onLoraChange() {
2586 extension_settings.sd.lora = $('#sd_lora').find(':selected').val();
2587 rememberWorkflowPref('lora', extension_settings.sd.lora);
2588 saveSettingsDebounced();
2589 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2590 pushRunpodCatalog();
2591 }
2592}
2593
2594function onComfyWorkflowChange() {
2595 extension_settings.sd.comfy_workflow = $('#sd_comfy_workflow').find(':selected').val();
2596 // Restore the model/lora remembered for this workflow (auto-configuration
2597 // when switching between e.g. the flux and qwen workflow families).
2598 const pref = extension_settings.sd.comfy_workflow_prefs?.[extension_settings.sd.comfy_workflow];
2599 if (pref) {
2600 if (pref.model) {
2601 extension_settings.sd.model = pref.model;
2602 $('#sd_model').val(pref.model);
2603 }
2604 if (pref.lora !== undefined) {
2605 extension_settings.sd.lora = pref.lora;
2606 $('#sd_lora').val(pref.lora);
2607 }
2608 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2609 pushRunpodCatalog();
2610 }
2611 }
2612 saveSettingsDebounced();
2613}
2614
2615function onBflUpsamplingInput() {
2616 extension_settings.sd.bfl_upsampling = !!$('#sd_bfl_upsampling').prop('checked');
2617 saveSettingsDebounced();
2618}
2619
2620function onStabilityStylePresetChange() {
2621 extension_settings.sd.stability_style_preset = String($('#sd_stability_style_preset').val());
2622 saveSettingsDebounced();
2623}
2624
2625async function changeComfyWorkflow(_, name) {
2626 name = name.replace(/(\.json)?$/i, '.json');
2627 if ($(`#sd_comfy_workflow > [value="${name}"]`).length > 0) {
2628 extension_settings.sd.comfy_workflow = name;
2629 $('#sd_comfy_workflow').val(extension_settings.sd.comfy_workflow);
2630 saveSettingsDebounced();
2631 } else {
2632 toastr.error(`ComfyUI Workflow "${name}" does not exist.`);
2633 }
2634 return '';
2635}
2636
2637async function validateAutoUrl() {
2638 try {
2639 if (!extension_settings.sd.auto_url) {
2640 throw new Error('URL is not set.');
2641 }
2642
2643 const result = await fetch('/api/sd/ping', {
2644 method: 'POST',
2645 headers: getRequestHeaders(),
2646 body: JSON.stringify(getSdRequestBody()),
2647 });
2648
2649 if (!result.ok) {
2650 throw new Error('SD WebUI returned an error.');
2651 }
2652
2653 await loadSettingOptions();
2654 toastr.success('SD WebUI API connected.');
2655 } catch (error) {
2656 toastr.error(`Could not validate SD WebUI API: ${error.message}`);
2657 }
2658}
2659
2660async function validateSdcppUrl() {
2661 try {
2662 if (!extension_settings.sd.sdcpp_url) {
2663 throw new Error('URL is not set.');
2664 }
2665
2666 const result = await fetch('/api/sd/sdcpp/ping', {
2667 method: 'POST',
2668 headers: getRequestHeaders(),
2669 body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }),
2670 });
2671
2672 if (!result.ok) {
2673 throw new Error('stable-diffusion.cpp server returned an error.');
2674 }
2675
2676 await loadSettingOptions();
2677 toastr.success('stable-diffusion.cpp server connected.');
2678 } catch (error) {
2679 toastr.error(`Could not validate stable-diffusion.cpp server: ${error.message}`);
2680 }
2681}
2682
2683async function validateDrawthingsUrl() {
2684 try {
2685 if (!extension_settings.sd.drawthings_url) {
2686 throw new Error('URL is not set.');
2687 }
2688
2689 const result = await fetch('/api/sd/drawthings/ping', {
2690 method: 'POST',
2691 headers: getRequestHeaders(),
2692 body: JSON.stringify(getSdRequestBody()),
2693 });
2694
2695 if (!result.ok) {
2696 throw new Error('SD Drawthings returned an error.');
2697 }
2698
2699 await loadSettingOptions();
2700 toastr.success('SD Drawthings API connected.');
2701 } catch (error) {
2702 toastr.error(`Could not validate SD Drawthings API: ${error.message}`);
2703 }
2704}
2705
2706async function validateVladUrl() {
2707 try {
2708 if (!extension_settings.sd.vlad_url) {
2709 throw new Error('URL is not set.');
2710 }
2711
2712 const result = await fetch('/api/sd/ping', {
2713 method: 'POST',
2714 headers: getRequestHeaders(),
2715 body: JSON.stringify(getSdRequestBody()),
2716 });
2717
2718 if (!result.ok) {
2719 throw new Error('SD.Next returned an error.');
2720 }
2721
2722 await loadSettingOptions();
2723 toastr.success('SD.Next API connected.');
2724 } catch (error) {
2725 toastr.error(`Could not validate SD.Next API: ${error.message}`);
2726 }
2727}
2728
2729async function validateComfyUrl() {
2730 try {
2731 if (!extension_settings.sd.comfy_url) {
2732 throw new Error('URL is not set.');
2733 }
2734
2735 const result = await fetch('/api/sd/comfy/ping', {
2736 method: 'POST',
2737 headers: getRequestHeaders(),
2738 body: JSON.stringify({
2739 url: extension_settings.sd.comfy_url,
2740 }),
2741 });
2742 if (!result.ok) {
2743 throw new Error('ComfyUI returned an error.');
2744 }
2745
2746 await loadSettingOptions();
2747 toastr.success('ComfyUI API connected.');
2748 } catch (error) {
2749 toastr.error(`Could not validate ComfyUI API: ${error.message}`);
2750 }
2751}
2752
2753async function validateComfyRunPodUrl() {
2754 try {
2755 if (!extension_settings.sd.comfy_runpod_url) {
2756 throw new Error('URL is not set.');
2757 }
2758
2759 const result = await fetch('/api/sd/comfyrunpod/ping', {
2760 method: 'POST',
2761 headers: getRequestHeaders(),
2762 body: JSON.stringify({
2763 url: extension_settings.sd.comfy_runpod_url,
2764 }),
2765 });
2766 if (!result.ok) {
2767 throw new Error('ComfyUI RunPod returned an error.');
2768 }
2769
2770 await loadSettingOptions();
2771 toastr.success('ComfyUI RunPod API connected.');
2772 } catch (error) {
2773 toastr.error(`Could not validate ComfyUI RunPod API: ${error.message}`);
2774 }
2775}
2776
2777async function onModelChange() {
2778 const selectedModel = $('#sd_model').find(':selected');
2779 extension_settings.sd.model = selectedModel.val();
2780 rememberWorkflowPref('model', extension_settings.sd.model);
2781 saveSettingsDebounced();
2782
2783 // Keep the proxy's stored selection current, but catalog updates never
2784 // download, provision, or start a pod. Warm up applies the selection.
2785 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2786 pushRunpodCatalog();
2787 }
2788
2789 if (extension_settings.sd.model && extension_settings.sd.source === sources.electronhub) {
2790 const cachedModel = selectedModel.data('model');
2791 const models = cachedModel ? [cachedModel] : await loadElectronHubModels();
2792 ensureElectronHubQualitySelect(models);
2793 }
2794
2795 switchModelSpecificControls(extension_settings.sd.model);
2796
2797 const updateRemoteModelSources = [
2798 sources.auto,
2799 sources.vlad,
2800 sources.extras,
2801 ];
2802
2803 if (!updateRemoteModelSources.includes(extension_settings.sd.source)) {
2804 return;
2805 }
2806
2807 toastr.info('Updating remote model...', 'Please wait');
2808 if (extension_settings.sd.source === sources.extras) {
2809 await updateExtrasRemoteModel();
2810 }
2811 if (extension_settings.sd.source === sources.auto || extension_settings.sd.source === sources.vlad) {
2812 await updateAutoRemoteModel();
2813 }
2814 toastr.success('Model successfully loaded!', 'Image Generation');
2815}
2816
2817async function getAutoRemoteModel() {
2818 try {
2819 const result = await fetch('/api/sd/get-model', {
2820 method: 'POST',
2821 headers: getRequestHeaders(),
2822 body: JSON.stringify(getSdRequestBody()),
2823 });
2824
2825 if (!result.ok) {
2826 throw new Error('SD WebUI returned an error.');
2827 }
2828
2829 return await result.text();
2830 } catch (error) {
2831 console.error(error);
2832 return null;
2833 }
2834}
2835
2836async function getDrawthingsRemoteModel() {
2837 try {
2838 const result = await fetch('/api/sd/drawthings/get-model', {
2839 method: 'POST',
2840 headers: getRequestHeaders(),
2841 body: JSON.stringify(getSdRequestBody()),
2842 });
2843
2844 if (!result.ok) {
2845 throw new Error('SD DrawThings API returned an error.');
2846 }
2847
2848 return await result.text();
2849 } catch (error) {
2850 console.error(error);
2851 return null;
2852 }
2853}
2854
2855async function onVaeChange() {
2856 extension_settings.sd.vae = $('#sd_vae').find(':selected').val();
2857}
2858
2859async function getAutoRemoteUpscalers() {
2860 try {
2861 const result = await fetch('/api/sd/upscalers', {
2862 method: 'POST',
2863 headers: getRequestHeaders(),
2864 body: JSON.stringify(getSdRequestBody()),
2865 });
2866
2867 if (!result.ok) {
2868 throw new Error('SD WebUI returned an error.');
2869 }
2870
2871 return await result.json();
2872 } catch (error) {
2873 console.error(error);
2874 return [extension_settings.sd.hr_upscaler];
2875 }
2876}
2877
2878async function getAutoRemoteSchedulers() {
2879 try {
2880 const result = await fetch('/api/sd/schedulers', {
2881 method: 'POST',
2882 headers: getRequestHeaders(),
2883 body: JSON.stringify(getSdRequestBody()),
2884 });
2885
2886 if (!result.ok) {
2887 throw new Error('SD WebUI returned an error.');
2888 }
2889
2890 return await result.json();
2891 } catch (error) {
2892 console.error(error);
2893 return ['N/A'];
2894 }
2895}
2896
2897async function getVladRemoteUpscalers() {
2898 try {
2899 const result = await fetch('/api/sd/sd-next/upscalers', {
2900 method: 'POST',
2901 headers: getRequestHeaders(),
2902 body: JSON.stringify(getSdRequestBody()),
2903 });
2904
2905 if (!result.ok) {
2906 throw new Error('SD.Next returned an error.');
2907 }
2908
2909 return await result.json();
2910 } catch (error) {
2911 console.error(error);
2912 return [extension_settings.sd.hr_upscaler];
2913 }
2914}
2915
2916async function getDrawthingsRemoteUpscalers() {
2917 try {
2918 const result = await fetch('/api/sd/drawthings/get-upscaler', {
2919 method: 'POST',
2920 headers: getRequestHeaders(),
2921 body: JSON.stringify(getSdRequestBody()),
2922 });
2923
2924 if (!result.ok) {
2925 throw new Error('SD DrawThings API returned an error.');
2926 }
2927
2928 const data = await result.text();
2929
2930 return data ? [data] : ['N/A'];
2931 } catch (error) {
2932 console.error(error);
2933 return ['N/A'];
2934 }
2935}
2936
2937async function updateAutoRemoteModel() {
2938 try {
2939 const result = await fetch('/api/sd/set-model', {
2940 method: 'POST',
2941 headers: getRequestHeaders(),
2942 body: JSON.stringify({ ...getSdRequestBody(), model: extension_settings.sd.model }),
2943 });
2944
2945 if (!result.ok) {
2946 throw new Error('SD WebUI returned an error.');
2947 }
2948
2949 console.log('Model successfully updated on SD WebUI remote.');
2950 } catch (error) {
2951 console.error(error);
2952 toastr.error(`Could not update SD WebUI model: ${error.message}`);
2953 }
2954}
2955
2956async function updateExtrasRemoteModel() {
2957 const url = new URL(getApiUrl());
2958 url.pathname = '/api/image/model';
2959 const getCurrentModelResult = await doExtrasFetch(url, {
2960 method: 'POST',
2961 body: JSON.stringify({ model: extension_settings.sd.model }),
2962 });
2963
2964 if (getCurrentModelResult.ok) {
2965 console.log('Model successfully updated on SD remote.');
2966 }
2967}
2968
2969async function loadSamplers() {
2970 $('#sd_sampler').empty();
2971 let samplers = [];
2972
2973 switch (extension_settings.sd.source) {
2974 case sources.extras:
2975 samplers = await loadExtrasSamplers();
2976 break;
2977 case sources.horde:
2978 samplers = await loadHordeSamplers();
2979 break;
2980 case sources.auto:
2981 samplers = await loadAutoSamplers();
2982 break;
2983 case sources.sdcpp:
2984 samplers = await loadSdcppSamplers();
2985 break;
2986 case sources.drawthings:
2987 samplers = await loadDrawthingsSamplers();
2988 break;
2989 case sources.novel:
2990 samplers = await loadNovelSamplers();
2991 break;
2992 case sources.vlad:
2993 samplers = await loadVladSamplers();
2994 break;
2995 case sources.openai:
2996 samplers = ['N/A'];
2997 break;
2998 case sources.aimlapi:
2999 samplers = ['N/A'];
3000 break;
3001 case sources.comfy:
3002 samplers = await loadComfySamplers();
3003 break;
3004 case sources.togetherai:
3005 samplers = ['N/A'];
3006 break;
3007 case sources.pollinations:
3008 samplers = ['N/A'];
3009 break;
3010 case sources.stability:
3011 samplers = ['N/A'];
3012 break;
3013 case sources.huggingface:
3014 samplers = ['N/A'];
3015 break;
3016 case sources.chutes:
3017 samplers = ['N/A'];
3018 break;
3019 case sources.electronhub:
3020 samplers = ['N/A'];
3021 break;
3022 case sources.nanogpt:
3023 samplers = ['N/A'];
3024 break;
3025 case sources.bfl:
3026 samplers = ['N/A'];
3027 break;
3028 case sources.falai:
3029 samplers = ['N/A'];
3030 break;
3031 case sources.xai:
3032 samplers = ['N/A'];
3033 break;
3034 case sources.google:
3035 samplers = ['N/A'];
3036 break;
3037 case sources.zai:
3038 samplers = ['N/A'];
3039 break;
3040 case sources.openrouter:
3041 samplers = ['N/A'];
3042 break;
3043 case sources.workersai:
3044 samplers = ['N/A'];
3045 break;
3046 }
3047
3048 for (const sampler of samplers) {
3049 const option = document.createElement('option');
3050 option.innerText = sampler;
3051 option.value = sampler;
3052 option.selected = sampler === extension_settings.sd.sampler;
3053 $('#sd_sampler').append(option);
3054 }
3055
3056 if (!extension_settings.sd.sampler && samplers.length > 0) {
3057 extension_settings.sd.sampler = samplers[0];
3058 $('#sd_sampler').val(extension_settings.sd.sampler).trigger('change');
3059 }
3060}
3061
3062async function loadHordeSamplers() {
3063 const result = await fetch('/api/horde/sd-samplers', {
3064 method: 'POST',
3065 headers: getRequestHeaders({ omitContentType: true }),
3066 });
3067
3068 if (result.ok) {
3069 return await result.json();
3070 }
3071
3072 return [];
3073}
3074
3075async function loadExtrasSamplers() {
3076 if (!modules.includes('sd')) {
3077 return [];
3078 }
3079
3080 const url = new URL(getApiUrl());
3081 url.pathname = '/api/image/samplers';
3082 const result = await doExtrasFetch(url);
3083
3084 if (result.ok) {
3085 const data = await result.json();
3086 return data.samplers;
3087 }
3088
3089 return [];
3090}
3091
3092async function loadAutoSamplers() {
3093 if (!extension_settings.sd.auto_url) {
3094 return [];
3095 }
3096
3097 try {
3098 const result = await fetch('/api/sd/samplers', {
3099 method: 'POST',
3100 headers: getRequestHeaders(),
3101 body: JSON.stringify(getSdRequestBody()),
3102 });
3103
3104 if (!result.ok) {
3105 throw new Error('SD WebUI returned an error.');
3106 }
3107
3108 return await result.json();
3109 } catch (error) {
3110 return [];
3111 }
3112}
3113
3114async function loadSdcppModels() {
3115 if (!extension_settings.sd.sdcpp_url) {
3116 return [{ value: '', text: 'N/A' }];
3117 }
3118
3119 try {
3120 const result = await fetch('/api/sd/sdcpp/models', {
3121 method: 'POST',
3122 headers: getRequestHeaders(),
3123 body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }),
3124 });
3125
3126 if (!result.ok) {
3127 return [{ value: '', text: 'N/A' }];
3128 }
3129
3130 const data = await result.json();
3131
3132 if (data?.data?.length > 0) {
3133 return data.data.map(model => ({ value: model.id, text: model.name || model.id }));
3134 }
3135 } catch (error) {
3136 console.error('Failed to load sd.cpp models:', error);
3137 }
3138
3139 return [{ value: '', text: 'N/A' }];
3140}
3141
3142async function loadSdcppSamplers() {
3143 // The sdcpp server does not provide an API for samplers, so we return the known list.
3144 return ['euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd'];
3145}
3146
3147async function loadDrawthingsSamplers() {
3148 // The app developer doesn't provide an API to get these yet
3149 return [
3150 'UniPC',
3151 'DPM++ 2M Karras',
3152 'Euler a',
3153 'DPM++ SDE Karras',
3154 'PLMS',
3155 'DDIM',
3156 'LCM',
3157 'Euler A Substep',
3158 'DPM++ SDE Substep',
3159 'TCD',
3160 ];
3161}
3162
3163async function loadVladSamplers() {
3164 if (!extension_settings.sd.vlad_url) {
3165 return [];
3166 }
3167
3168 try {
3169 const result = await fetch('/api/sd/samplers', {
3170 method: 'POST',
3171 headers: getRequestHeaders(),
3172 body: JSON.stringify(getSdRequestBody()),
3173 });
3174
3175 if (!result.ok) {
3176 throw new Error('SD.Next returned an error.');
3177 }
3178
3179 return await result.json();
3180 } catch (error) {
3181 return [];
3182 }
3183}
3184
3185async function loadNovelSamplers() {
3186 return [
3187 'k_euler_ancestral',
3188 'k_euler',
3189 'k_dpmpp_2m',
3190 'k_dpmpp_sde',
3191 'k_dpmpp_2s_ancestral',
3192 'k_dpm_fast',
3193 'ddim',
3194 ];
3195}
3196
3197async function loadComfySamplers() {
3198 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
3199 return ['N/A'];
3200 }
3201 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
3202 // Do not query /object_info through a cold lazy proxy on page load.
3203 return extension_settings.sd.sampler ? [extension_settings.sd.sampler] : [];
3204 }
3205 if (!extension_settings.sd.comfy_url) {
3206 return [];
3207 }
3208
3209 try {
3210 const result = await fetch('/api/sd/comfy/samplers', {
3211 method: 'POST',
3212 headers: getRequestHeaders(),
3213 body: JSON.stringify({
3214 url: extension_settings.sd.comfy_url,
3215 }),
3216 });
3217 if (!result.ok) {
3218 throw new Error('ComfyUI returned an error.');
3219 }
3220 return await result.json();
3221 } catch (error) {
3222 return [];
3223 }
3224}
3225
3226async function loadModels() {
3227 $('#sd_model').empty();
3228 let models = [];
3229
3230 switch (extension_settings.sd.source) {
3231 case sources.extras:
3232 models = await loadExtrasModels();
3233 break;
3234 case sources.horde:
3235 models = await loadHordeModels();
3236 break;
3237 case sources.auto:
3238 models = await loadAutoModels();
3239 break;
3240 case sources.sdcpp:
3241 models = await loadSdcppModels();
3242 break;
3243 case sources.drawthings:
3244 models = await loadDrawthingsModels();
3245 break;
3246 case sources.novel:
3247 models = await loadNovelModels();
3248 break;
3249 case sources.vlad:
3250 models = await loadVladModels();
3251 break;
3252 case sources.openai:
3253 models = await loadOpenAiModels();
3254 break;
3255 case sources.aimlapi:
3256 models = await loadAimlapiModels();
3257 break;
3258 case sources.comfy:
3259 models = await loadComfyModels();
3260 break;
3261 case sources.togetherai:
3262 models = await loadTogetherAIModels();
3263 break;
3264 case sources.pollinations:
3265 models = await loadPollinationsModels();
3266 break;
3267 case sources.stability:
3268 models = await loadStabilityModels();
3269 break;
3270 case sources.huggingface:
3271 models = [{ value: '', text: t`<Enter Model ID above>` }];
3272 break;
3273 case sources.chutes:
3274 models = await loadChutesModels();
3275 break;
3276 case sources.electronhub:
3277 models = await loadElectronHubModels();
3278 break;
3279 case sources.nanogpt:
3280 models = await loadNanoGPTModels();
3281 break;
3282 case sources.bfl:
3283 models = await loadBflModels();
3284 break;
3285 case sources.falai:
3286 models = await loadFalaiModels();
3287 break;
3288 case sources.xai:
3289 models = await loadXAIModels();
3290 break;
3291 case sources.google:
3292 models = await loadGoogleModels();
3293 break;
3294 case sources.zai:
3295 models = await loadZaiModels();
3296 break;
3297 case sources.openrouter:
3298 models = await loadOpenRouterModels();
3299 break;
3300 case sources.workersai:
3301 models = await loadWorkersAIImageModels();
3302 break;
3303 }
3304
3305 if (extension_settings.sd.source === sources.electronhub) {
3306 ensureElectronHubQualitySelect(models);
3307 }
3308
3309 switchModelSpecificControls(extension_settings.sd.model);
3310
3311 for (const model of models) {
3312 const option = document.createElement('option');
3313 option.innerText = model.text;
3314 option.value = model.value;
3315 option.selected = model.value === extension_settings.sd.model;
3316 $(option).data('model', model);
3317 $('#sd_model').append(option);
3318 }
3319
3320 if (!extension_settings.sd.model && models.length > 0) {
3321 extension_settings.sd.model = models[0].value;
3322 $('#sd_model').val(extension_settings.sd.model).trigger('change');
3323 }
3324}
3325
3326/**
3327 * Show or hide model-specific controls based on the selected model.
3328 * @param {string} modelId Model ID
3329 */
3330function switchModelSpecificControls(modelId) {
3331 const modelControls = $('.sd_settings [data-sd-model]');
3332 modelControls.hide();
3333
3334 if (!modelId) {
3335 return;
3336 }
3337
3338 modelControls.each(function () {
3339 const models = String($(this).attr('data-sd-model') || '').split(',').map(m => m.trim());
3340 $(this).toggle(models.some(m => modelId.includes(m)));
3341 });
3342}
3343
3344/**
3345 * Ensure the Electron Hub quality select is populated based on the selected model.
3346 * @param {any[]} models Array of models
3347 */
3348function ensureElectronHubQualitySelect(models) {
3349 try {
3350 const modelId = String(extension_settings.sd.model || '');
3351 if (!modelId) return;
3352
3353 const model = Array.isArray(models) ? models.find(m => String(m?.id) === modelId) : undefined;
3354 const qualities = Array.isArray(model?.qualities) ? model.qualities : undefined;
3355
3356 const $qualityRow = $('#sd_electronhub_quality_row');
3357 const $select = $('#sd_electronhub_quality');
3358
3359 $qualityRow.toggle(!!qualities && qualities.length > 0);
3360 $select.empty();
3361
3362 if (!qualities || qualities.length === 0) {
3363 extension_settings.sd.electronhub_quality = undefined;
3364 saveSettingsDebounced();
3365 return;
3366 }
3367
3368 for (const q of qualities) {
3369 const opt = document.createElement('option');
3370 opt.value = String(q);
3371 opt.textContent = String(q);
3372 opt.selected = String(q) === String(extension_settings.sd.electronhub_quality || '');
3373 $select.append(opt);
3374 }
3375
3376 if (!$select.val()) {
3377 const first = String(qualities[0]);
3378 extension_settings.sd.electronhub_quality = first;
3379 $select.val(first);
3380 saveSettingsDebounced();
3381 }
3382 } catch (e) {
3383 console.error(e);
3384 }
3385}
3386
3387async function loadStabilityModels() {
3388 $('#sd_stability_key').toggleClass('success', !!secret_state[SECRET_KEYS.STABILITY]);
3389
3390 return [
3391 { value: 'stable-image-ultra', text: 'Stable Image Ultra' },
3392 { value: 'stable-image-core', text: 'Stable Image Core' },
3393 { value: 'stable-diffusion-3', text: 'Stable Diffusion 3' },
3394 ];
3395}
3396
3397async function loadBflModels() {
3398 $('#sd_bfl_key').toggleClass('success', !!secret_state[SECRET_KEYS.BFL]);
3399
3400 return [
3401 { value: 'flux-pro-1.1-ultra', text: 'flux-pro-1.1-ultra' },
3402 { value: 'flux-pro-1.1', text: 'flux-pro-1.1' },
3403 { value: 'flux-pro', text: 'flux-pro' },
3404 { value: 'flux-dev', text: 'flux-dev' },
3405 ];
3406}
3407
3408async function loadFalaiModels() {
3409 $('#sd_falai_key').toggleClass('success', !!secret_state[SECRET_KEYS.FALAI]);
3410
3411 const result = await fetch('/api/sd/falai/models', {
3412 method: 'POST',
3413 headers: getRequestHeaders({ omitContentType: true }),
3414 });
3415
3416 if (result.ok) {
3417 return await result.json();
3418 }
3419
3420 return [];
3421}
3422
3423async function loadXAIModels() {
3424 return [
3425 { value: 'grok-imagine-image', text: 'grok-imagine-image' },
3426 { value: 'grok-imagine-image-pro', text: 'grok-imagine-image-pro' },
3427 ];
3428}
3429
3430async function loadWorkersAIImageModels() {
3431 $('#sd_cf_workers_key').toggleClass('success', !!secret_state[SECRET_KEYS.WORKERS_AI]);
3432
3433 if (!secret_state[SECRET_KEYS.WORKERS_AI]) {
3434 return [];
3435 }
3436
3437 if (!oai_settings.workers_ai_account_id) {
3438 toastr.warning('Workers AI account ID is required. Save it in the "API Connections" panel.', 'Image Generation');
3439 return [];
3440 }
3441
3442 const result = await fetch('/api/sd/workersai/models', {
3443 method: 'POST',
3444 headers: getRequestHeaders(),
3445 body: JSON.stringify({
3446 account_id: oai_settings.workers_ai_account_id,
3447 }),
3448 });
3449
3450 if (result.ok) {
3451 return await result.json();
3452 }
3453
3454 return [];
3455}
3456
3457async function loadPollinationsModels() {
3458 $('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]);
3459
3460 const result = await fetch('/api/sd/pollinations/models', {
3461 method: 'POST',
3462 headers: getRequestHeaders({ omitContentType: true }),
3463 });
3464
3465 if (result.ok) {
3466 return await result.json();
3467 }
3468
3469 return [];
3470}
3471
3472async function loadTogetherAIModels() {
3473 if (!secret_state[SECRET_KEYS.TOGETHERAI]) {
3474 console.debug('TogetherAI API key is not set.');
3475 return [];
3476 }
3477
3478 const result = await fetch('/api/sd/together/models', {
3479 method: 'POST',
3480 headers: getRequestHeaders({ omitContentType: true }),
3481 });
3482
3483 if (result.ok) {
3484 return await result.json();
3485 }
3486
3487 return [];
3488}
3489
3490async function loadChutesModels() {
3491 if (!secret_state[SECRET_KEYS.CHUTES]) {
3492 console.debug('Chutes API key is not set.');
3493 return [];
3494 }
3495
3496 const result = await fetch('/api/sd/chutes/models', {
3497 method: 'POST',
3498 headers: getRequestHeaders({ omitContentType: true }),
3499 });
3500
3501 if (result.ok) {
3502 const models = await result.json();
3503 console.debug('Loaded Chutes image models:', models);
3504 return models;
3505 }
3506
3507 console.warn('Failed to load Chutes models:', result.status);
3508 return [];
3509}
3510
3511async function loadElectronHubModels() {
3512 if (!secret_state[SECRET_KEYS.ELECTRONHUB]) {
3513 console.debug('Electron Hub API key is not set.');
3514 return [];
3515 }
3516
3517 const result = await fetch('/api/sd/electronhub/models', {
3518 method: 'POST',
3519 headers: getRequestHeaders({ omitContentType: true }),
3520 });
3521
3522 function getModelName(model) {
3523 const name = String(model?.name || model?.id || '');
3524 const premium = model?.premium_model ? ' | Premium' : '';
3525 let price = 'Unknown';
3526 if (model?.pricing?.type === 'per_image') {
3527 const coeff = Number(model.pricing.coefficient);
3528 if (!isNaN(coeff)) {
3529 price = `$${coeff}/image`;
3530 }
3531 }
3532 return `${name} | ${price}${premium}`;
3533 }
3534
3535 if (result.ok) {
3536 /** @type {any[]} */
3537 const data = await result.json();
3538 return Array.isArray(data) ? data.map(m => ({ ...m, text: getModelName(m) })) : [];
3539 }
3540
3541 return [];
3542}
3543
3544async function loadNanoGPTModels() {
3545 if (!secret_state[SECRET_KEYS.NANOGPT]) {
3546 console.debug('NanoGPT API key is not set.');
3547 return [];
3548 }
3549
3550 const result = await fetch('/api/sd/nanogpt/models', {
3551 method: 'POST',
3552 headers: getRequestHeaders({ omitContentType: true }),
3553 });
3554
3555 if (result.ok) {
3556 return await result.json();
3557 }
3558
3559 return [];
3560}
3561
3562async function loadHordeModels() {
3563 const result = await fetch('/api/horde/sd-models', {
3564 method: 'POST',
3565 headers: getRequestHeaders({ omitContentType: true }),
3566 });
3567
3568
3569 if (result.ok) {
3570 const data = await result.json();
3571 data.sort((a, b) => b.count - a.count);
3572 return data.map(x => ({
3573 value: x.name,
3574 text: `${x.name} (ETA: ${x.eta}s, Queue: ${x.queued}, Workers: ${x.count})`,
3575 }));
3576 }
3577
3578 return [];
3579}
3580
3581async function loadExtrasModels() {
3582 if (!modules.includes('sd')) {
3583 return [];
3584 }
3585
3586 const url = new URL(getApiUrl());
3587 url.pathname = '/api/image/model';
3588 const getCurrentModelResult = await doExtrasFetch(url);
3589
3590 if (getCurrentModelResult.ok) {
3591 const data = await getCurrentModelResult.json();
3592 extension_settings.sd.model = data.model;
3593 }
3594
3595 url.pathname = '/api/image/models';
3596 const getModelsResult = await doExtrasFetch(url);
3597
3598 if (getModelsResult.ok) {
3599 const data = await getModelsResult.json();
3600 return data.models.map(x => ({ value: x, text: x }));
3601 }
3602
3603 return [];
3604}
3605
3606async function loadAutoModels() {
3607 if (!extension_settings.sd.auto_url) {
3608 return [];
3609 }
3610
3611 try {
3612 const currentModel = await getAutoRemoteModel();
3613
3614 if (currentModel) {
3615 extension_settings.sd.model = currentModel;
3616 }
3617
3618 const result = await fetch('/api/sd/models', {
3619 method: 'POST',
3620 headers: getRequestHeaders(),
3621 body: JSON.stringify(getSdRequestBody()),
3622 });
3623
3624 if (!result.ok) {
3625 throw new Error('SD WebUI returned an error.');
3626 }
3627
3628 const upscalers = await getAutoRemoteUpscalers();
3629
3630 if (Array.isArray(upscalers) && upscalers.length > 0) {
3631 $('#sd_hr_upscaler').empty();
3632
3633 for (const upscaler of upscalers) {
3634 const option = document.createElement('option');
3635 option.innerText = upscaler;
3636 option.value = upscaler;
3637 option.selected = upscaler === extension_settings.sd.hr_upscaler;
3638 $('#sd_hr_upscaler').append(option);
3639 }
3640 }
3641
3642 return await result.json();
3643 } catch (error) {
3644 return [];
3645 }
3646}
3647
3648async function loadDrawthingsModels() {
3649 if (!extension_settings.sd.drawthings_url) {
3650 return [];
3651 }
3652
3653 try {
3654 const currentModel = await getDrawthingsRemoteModel();
3655
3656 if (currentModel) {
3657 extension_settings.sd.model = currentModel;
3658 }
3659
3660 const data = [{ value: currentModel, text: currentModel }];
3661
3662
3663 const upscalers = await getDrawthingsRemoteUpscalers();
3664
3665 if (Array.isArray(upscalers) && upscalers.length > 0) {
3666 $('#sd_hr_upscaler').empty();
3667
3668 for (const upscaler of upscalers) {
3669 const option = document.createElement('option');
3670 option.innerText = upscaler;
3671 option.value = upscaler;
3672 option.selected = upscaler === extension_settings.sd.hr_upscaler;
3673 $('#sd_hr_upscaler').append(option);
3674 }
3675 }
3676
3677 return data;
3678 } catch (error) {
3679 console.log('Error loading DrawThings API models:', error);
3680 return [];
3681 }
3682}
3683
3684async function loadOpenAiModels() {
3685 return [
3686 { value: 'gpt-image-2', text: 'gpt-image-2' },
3687 { value: 'gpt-image-2-2026-04-21', text: 'gpt-image-2-2026-04-21' },
3688 { value: 'gpt-image-1.5', text: 'gpt-image-1.5' },
3689 { value: 'gpt-image-1-mini', text: 'gpt-image-1-mini' },
3690 { value: 'gpt-image-1', text: 'gpt-image-1' },
3691 { value: 'chatgpt-image-latest', text: 'chatgpt-image-latest' },
3692 { value: 'dall-e-3', text: 'dall-e-3' },
3693 { value: 'dall-e-2', text: 'dall-e-2' },
3694 { value: 'sora-2', text: 'sora-2' },
3695 { value: 'sora-2-pro', text: 'sora-2-pro' },
3696 ];
3697}
3698
3699async function loadAimlapiModels() {
3700 $('#sd_aimlapi_key').toggleClass('success', !!secret_state[SECRET_KEYS.AIMLAPI]);
3701
3702 const result = await fetch('/api/sd/aimlapi/models', {
3703 method: 'POST',
3704 headers: getRequestHeaders({ omitContentType: true }),
3705 });
3706
3707 if (!result.ok) {
3708 return [];
3709 }
3710
3711 const json = await result.json();
3712
3713 return (json.data || []);
3714}
3715
3716async function loadVladModels() {
3717 if (!extension_settings.sd.vlad_url) {
3718 return [];
3719 }
3720
3721 try {
3722 const currentModel = await getAutoRemoteModel();
3723
3724 if (currentModel) {
3725 extension_settings.sd.model = currentModel;
3726 }
3727
3728 const result = await fetch('/api/sd/models', {
3729 method: 'POST',
3730 headers: getRequestHeaders(),
3731 body: JSON.stringify(getSdRequestBody()),
3732 });
3733
3734 if (!result.ok) {
3735 throw new Error('SD WebUI returned an error.');
3736 }
3737
3738 const upscalers = await getVladRemoteUpscalers();
3739
3740 if (Array.isArray(upscalers) && upscalers.length > 0) {
3741 $('#sd_hr_upscaler').empty();
3742
3743 for (const upscaler of upscalers) {
3744 const option = document.createElement('option');
3745 option.innerText = upscaler;
3746 option.value = upscaler;
3747 option.selected = upscaler === extension_settings.sd.hr_upscaler;
3748 $('#sd_hr_upscaler').append(option);
3749 }
3750 }
3751
3752 return await result.json();
3753 } catch (error) {
3754 return [];
3755 }
3756}
3757
3758async function loadNovelModels() {
3759 return [
3760 {
3761 value: 'nai-diffusion-4-5-full',
3762 text: 'NAI Diffusion Anime V4.5 (Full)',
3763 },
3764 {
3765 value: 'nai-diffusion-4-5-curated',
3766 text: 'NAI Diffusion Anime V4.5 (Curated)',
3767 },
3768 {
3769 value: 'nai-diffusion-4-full',
3770 text: 'NAI Diffusion Anime V4 (Full)',
3771 },
3772 {
3773 value: 'nai-diffusion-4-curated-preview',
3774 text: 'NAI Diffusion Anime V4 (Curated)',
3775 },
3776 {
3777 value: 'nai-diffusion-3',
3778 text: 'NAI Diffusion Anime V3',
3779 },
3780 {
3781 value: 'nai-diffusion-2',
3782 text: 'NAI Diffusion Anime V2',
3783 },
3784 {
3785 value: 'nai-diffusion-furry-3',
3786 text: 'NAI Diffusion Furry V3',
3787 },
3788 ];
3789}
3790
3791async function loadGoogleModels() {
3792 return [
3793 'imagen-4.0-generate-001',
3794 'imagen-4.0-ultra-generate-001',
3795 'imagen-4.0-fast-generate-001',
3796 'imagen-4.0-generate-preview-06-06',
3797 'imagen-4.0-fast-generate-preview-06-06',
3798 'imagen-4.0-ultra-generate-preview-06-06',
3799 'imagen-3.0-generate-002',
3800 'imagen-3.0-generate-001',
3801 'imagen-3.0-fast-generate-001',
3802 'imagen-3.0-capability-001',
3803 'imagegeneration@006',
3804 'imagegeneration@005',
3805 'imagegeneration@002',
3806 'veo-3.1-generate-preview',
3807 'veo-3.1-fast-generate-preview',
3808 'veo-3.0-generate-001',
3809 'veo-3.0-fast-generate-001',
3810 'veo-2.0-generate-001',
3811 'veo-2.0-generate-exp',
3812 'veo-2.0-generate-preview',
3813 ].map(name => ({ value: name, text: name }));
3814}
3815
3816async function loadZaiModels() {
3817 return [
3818 { value: 'glm-image', text: 'GLM-Image' },
3819 { value: 'cogview-4-250304', text: 'CogView-4' },
3820 { value: 'cogvideox-3', text: 'CogVideoX-3' },
3821 { value: 'viduq1-text', text: 'Viduq1-Text' },
3822 ];
3823}
3824
3825async function loadOpenRouterModels() {
3826 const result = await fetch('/api/openrouter/models/image', {
3827 method: 'POST',
3828 headers: getRequestHeaders({ omitContentType: true }),
3829 });
3830
3831 if (result.ok) {
3832 return await result.json();
3833 }
3834
3835 return [];
3836}
3837
3838function loadNovelSchedulers() {
3839 return ['karras', 'native', 'exponential', 'polyexponential'];
3840}
3841
3842async function loadComfyModels() {
3843 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
3844 // The RunPod pod downloads models on demand: list the configured catalog
3845 // instead of asking the (possibly cold) backend what it has on disk.
3846 return getRunpodModelEntries().map(m => ({ value: m.value, text: m.name || m.value }));
3847 }
3848 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
3849 $('#sd_runpod_key').toggleClass('success', !!secret_state[SECRET_KEYS.COMFY_RUNPOD]);
3850 return [
3851 { value: '', text: 'N/A' },
3852 ];
3853 }
3854 if (!extension_settings.sd.comfy_url) {
3855 return [];
3856 }
3857
3858 try {
3859 const result = await fetch('/api/sd/comfy/models', {
3860 method: 'POST',
3861 headers: getRequestHeaders(),
3862 body: JSON.stringify({
3863 url: extension_settings.sd.comfy_url,
3864 }),
3865 });
3866 if (!result.ok) {
3867 throw new Error('ComfyUI returned an error.');
3868 }
3869 return await result.json();
3870 } catch (error) {
3871 return [];
3872 }
3873}
3874
3875async function loadSchedulers() {
3876 $('#sd_scheduler').empty();
3877 let schedulers = [];
3878
3879 switch (extension_settings.sd.source) {
3880 case sources.extras:
3881 schedulers = ['N/A'];
3882 break;
3883 case sources.horde:
3884 schedulers = ['N/A'];
3885 break;
3886 case sources.auto:
3887 schedulers = await getAutoRemoteSchedulers();
3888 break;
3889 case sources.sdcpp:
3890 schedulers = await loadSdcppSchedulers();
3891 break;
3892 case sources.novel:
3893 schedulers = loadNovelSchedulers();
3894 break;
3895 case sources.vlad:
3896 schedulers = ['N/A'];
3897 break;
3898 case sources.drawthings:
3899 schedulers = ['N/A'];
3900 break;
3901 case sources.openai:
3902 schedulers = ['N/A'];
3903 break;
3904 case sources.aimlapi:
3905 schedulers = ['N/A'];
3906 break;
3907 case sources.togetherai:
3908 schedulers = ['N/A'];
3909 break;
3910 case sources.pollinations:
3911 schedulers = ['N/A'];
3912 break;
3913 case sources.comfy:
3914 schedulers = await loadComfySchedulers();
3915 break;
3916 case sources.stability:
3917 schedulers = ['N/A'];
3918 break;
3919 case sources.huggingface:
3920 schedulers = ['N/A'];
3921 break;
3922 case sources.chutes:
3923 schedulers = ['N/A'];
3924 break;
3925 case sources.electronhub:
3926 schedulers = ['N/A'];
3927 break;
3928 case sources.nanogpt:
3929 schedulers = ['N/A'];
3930 break;
3931 case sources.bfl:
3932 schedulers = ['N/A'];
3933 break;
3934 case sources.falai:
3935 schedulers = ['N/A'];
3936 break;
3937 case sources.xai:
3938 schedulers = ['N/A'];
3939 break;
3940 case sources.google:
3941 schedulers = ['N/A'];
3942 break;
3943 case sources.zai:
3944 schedulers = ['N/A'];
3945 break;
3946 case sources.openrouter:
3947 schedulers = ['N/A'];
3948 break;
3949 case sources.workersai:
3950 schedulers = ['N/A'];
3951 break;
3952 }
3953
3954 for (const scheduler of schedulers) {
3955 const option = document.createElement('option');
3956 option.innerText = scheduler;
3957 option.value = scheduler;
3958 option.selected = scheduler === extension_settings.sd.scheduler;
3959 $('#sd_scheduler').append(option);
3960 }
3961
3962 if (!extension_settings.sd.scheduler && schedulers.length > 0 && schedulers[0] !== 'N/A') {
3963 extension_settings.sd.scheduler = schedulers[0];
3964 $('#sd_scheduler').val(extension_settings.sd.scheduler).trigger('change');
3965 }
3966}
3967
3968async function loadComfySchedulers() {
3969 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
3970 return ['N/A'];
3971 }
3972 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
3973 // Do not query /object_info through a cold lazy proxy on page load.
3974 return extension_settings.sd.scheduler ? [extension_settings.sd.scheduler] : [];
3975 }
3976 if (!extension_settings.sd.comfy_url) {
3977 return [];
3978 }
3979
3980 try {
3981 const result = await fetch('/api/sd/comfy/schedulers', {
3982 method: 'POST',
3983 headers: getRequestHeaders(),
3984 body: JSON.stringify({
3985 url: extension_settings.sd.comfy_url,
3986 }),
3987 });
3988 if (!result.ok) {
3989 throw new Error('ComfyUI returned an error.');
3990 }
3991 return await result.json();
3992 } catch (error) {
3993 return [];
3994 }
3995}
3996
3997async function loadSdcppSchedulers() {
3998 // The sdcpp server does not provide an API for schedulers, so we return the known list.
3999 return ['discrete', 'karras', 'exponential', 'ays', 'gits', 'smoothstep', 'sgm_uniform', 'simple', 'kl_optimal', 'lcm'];
4000}
4001
4002async function loadVaes() {
4003 $('#sd_vae').empty();
4004 let vaes = [];
4005
4006 switch (extension_settings.sd.source) {
4007 case sources.extras:
4008 vaes = ['N/A'];
4009 break;
4010 case sources.horde:
4011 vaes = ['N/A'];
4012 break;
4013 case sources.auto:
4014 vaes = await loadAutoVaes();
4015 break;
4016 case sources.sdcpp:
4017 vaes = ['N/A'];
4018 break;
4019 case sources.novel:
4020 vaes = ['N/A'];
4021 break;
4022 case sources.vlad:
4023 vaes = ['N/A'];
4024 break;
4025 case sources.drawthings:
4026 vaes = ['N/A'];
4027 break;
4028 case sources.openai:
4029 vaes = ['N/A'];
4030 break;
4031 case sources.aimlapi:
4032 vaes = ['N/A'];
4033 break;
4034 case sources.togetherai:
4035 vaes = ['N/A'];
4036 break;
4037 case sources.pollinations:
4038 vaes = ['N/A'];
4039 break;
4040 case sources.comfy:
4041 vaes = await loadComfyVaes();
4042 break;
4043 case sources.stability:
4044 vaes = ['N/A'];
4045 break;
4046 case sources.huggingface:
4047 vaes = ['N/A'];
4048 break;
4049 case sources.chutes:
4050 vaes = ['N/A'];
4051 break;
4052 case sources.electronhub:
4053 vaes = ['N/A'];
4054 break;
4055 case sources.nanogpt:
4056 vaes = ['N/A'];
4057 break;
4058 case sources.bfl:
4059 vaes = ['N/A'];
4060 break;
4061 case sources.falai:
4062 vaes = ['N/A'];
4063 break;
4064 case sources.xai:
4065 vaes = ['N/A'];
4066 break;
4067 case sources.google:
4068 vaes = ['N/A'];
4069 break;
4070 case sources.zai:
4071 vaes = ['N/A'];
4072 break;
4073 case sources.openrouter:
4074 vaes = ['N/A'];
4075 break;
4076 case sources.workersai:
4077 vaes = ['N/A'];
4078 break;
4079 }
4080
4081 for (const vae of vaes) {
4082 const option = document.createElement('option');
4083 option.innerText = vae;
4084 option.value = vae;
4085 option.selected = vae === extension_settings.sd.vae;
4086 $('#sd_vae').append(option);
4087 }
4088
4089 // Snap to the first real entry when nothing is selected OR the saved value
4090 // is not offered (otherwise the dropdown displays the first option while
4091 // generations keep sending the stale saved value).
4092 if (vaes.length > 0 && vaes[0] !== 'N/A' && !vaes.includes(extension_settings.sd.vae)) {
4093 extension_settings.sd.vae = vaes[0];
4094 $('#sd_vae').val(extension_settings.sd.vae).trigger('change');
4095 }
4096}
4097
4098async function loadAutoVaes() {
4099 if (!extension_settings.sd.auto_url) {
4100 return ['N/A'];
4101 }
4102
4103 try {
4104 const result = await fetch('/api/sd/vaes', {
4105 method: 'POST',
4106 headers: getRequestHeaders(),
4107 body: JSON.stringify(getSdRequestBody()),
4108 });
4109
4110 if (!result.ok) {
4111 throw new Error('SD WebUI returned an error.');
4112 }
4113
4114 const data = await result.json();
4115 Array.isArray(data) && data.unshift(placeholderVae);
4116 return data;
4117 } catch (error) {
4118 return ['N/A'];
4119 }
4120}
4121
4122async function loadComfyVaes() {
4123 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
4124 return ['N/A'];
4125 }
4126 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
4127 // Derive the VAE list from the catalog's download manifests (dest under
4128 // vae/) instead of querying the pod, which may be cold.
4129 const vaes = getRunpodCatalog()
4130 .flatMap(m => parseRunpodFiles(m.downloads))
4131 .filter(f => /^vae\//i.test(f.dest))
4132 .map(f => f.dest.split('/').pop());
4133 return [...new Set(vaes)];
4134 }
4135 if (!extension_settings.sd.comfy_url) {
4136 return [];
4137 }
4138
4139 try {
4140 const result = await fetch('/api/sd/comfy/vaes', {
4141 method: 'POST',
4142 headers: getRequestHeaders(),
4143 body: JSON.stringify({
4144 url: extension_settings.sd.comfy_url,
4145 }),
4146 });
4147 if (!result.ok) {
4148 throw new Error('ComfyUI returned an error.');
4149 }
4150 return await result.json();
4151 } catch (error) {
4152 return [];
4153 }
4154}
4155
4156async function loadComfyWorkflows() {
4157 try {
4158 $('#sd_comfy_workflow').empty();
4159 const result = await fetch('/api/sd/comfy/workflows', {
4160 method: 'POST',
4161 headers: getRequestHeaders(),
4162 body: JSON.stringify({
4163 url: extension_settings.sd.comfy_url,
4164 }),
4165 });
4166 if (!result.ok) {
4167 throw new Error('ComfyUI returned an error.');
4168 }
4169 const workflows = await result.json();
4170 for (const workflow of workflows) {
4171 const option = document.createElement('option');
4172 option.innerText = workflow;
4173 option.value = workflow;
4174 option.selected = workflow === extension_settings.sd.comfy_workflow;
4175 $('#sd_comfy_workflow').append(option);
4176 }
4177 } catch (error) {
4178 console.error(`Could not load ComfyUI workflows: ${error.message}`);
4179 }
4180}
4181
4182function getGenerationType(prompt) {
4183 // Custom wand entries use the trigger convention 'custom_<id>' and bypass
4184 // the multimodal/free_extend transforms applied to the built-in triggers.
4185 const trimmedPrompt = String(prompt).trim();
4186 if (Array.isArray(extension_settings.sd.custom_entries)) {
4187 const customEntry = extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === trimmedPrompt);
4188 if (customEntry) {
4189 return generationMode.CUSTOM;
4190 }
4191 }
4192
4193 let mode = generationMode.FREE;
4194
4195 for (const [key, values] of Object.entries(triggerWords)) {
4196 for (const value of values) {
4197 if (value.toLowerCase() === prompt.toLowerCase().trim()) {
4198 mode = Number(key);
4199 break;
4200 }
4201 }
4202 }
4203
4204 if (extension_settings.sd.multimodal_captioning && multimodalMap[mode] !== undefined) {
4205 mode = multimodalMap[mode];
4206 }
4207
4208 if (mode === generationMode.FREE && extension_settings.sd.free_extend) {
4209 mode = generationMode.FREE_EXTENDED;
4210 }
4211
4212 return mode;
4213}
4214
4215function getQuietPrompt(mode, trigger) {
4216 if (mode === generationMode.CUSTOM) {
4217 const entry = Array.isArray(extension_settings.sd.custom_entries)
4218 ? extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === String(trigger).trim())
4219 : undefined;
4220 return entry ? entry.prompt : trigger;
4221 }
4222
4223 if (mode === generationMode.FREE) {
4224 return trigger;
4225 }
4226
4227 return stringFormat(extension_settings.sd.prompts[mode], trigger);
4228}
4229
4230/**
4231 * Sanitizes generated prompt for image generation.
4232 * @param {string} str String to process
4233 * @returns {string} Processed reply
4234 */
4235function processReply(str) {
4236 if (!str) {
4237 return '';
4238 }
4239
4240 if (extension_settings.sd.minimal_prompt_processing) {
4241 // Minimal prompt processing
4242 // JSON and similar should be preserved
4243 str = str.normalize('NFD');
4244 str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one
4245 str = str.trim();
4246 return str;
4247 }
4248
4249 str = str.replaceAll('"', '');
4250 str = str.replaceAll('“', '');
4251 str = str.replaceAll('\n', ', ');
4252 str = str.normalize('NFD');
4253
4254 // Strip out non-alphanumeric characters barring model syntax exceptions
4255 str = str.replace(/[^a-zA-Z0-9.,:_(){}<>[\]/\-'|#]+/g, ' ');
4256
4257 str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one
4258 str = str.trim();
4259
4260 str = str
4261 .split(',') // list split by commas
4262 .map(x => x.trim()) // trim each entry
4263 .filter(x => x) // remove empty entries
4264 .join(', '); // join it back with proper spacing
4265
4266 return str;
4267}
4268
4269function getRawLastMessage() {
4270 const getLastUsableMessage = () => {
4271 for (const message of context.chat.slice().reverse()) {
4272 if (message.is_system) {
4273 continue;
4274 }
4275
4276 return {
4277 mes: message.mes,
4278 original_avatar: message.original_avatar,
4279 };
4280 }
4281
4282 toastr.warning('No usable messages found.', 'Image Generation');
4283 throw new Error('No usable messages found.');
4284 };
4285
4286 const context = getContext();
4287 const lastMessage = getLastUsableMessage();
4288 const character = context.groupId
4289 ? context.characters.find(c => c.avatar === lastMessage.original_avatar)
4290 : context.characters[context.characterId];
4291
4292 if (!character) {
4293 console.debug('Character not found, using raw message.');
4294 return processReply(lastMessage.mes);
4295 }
4296
4297 return `((${processReply(lastMessage.mes)})), (${processReply(character.scenario)}:0.7), (${processReply(character.description)}:0.5)`;
4298}
4299
4300/**
4301 * Ensure that the selected option exists in the dropdown.
4302 * @param {string} setting Setting key
4303 * @param {string} selector Dropdown selector
4304 * @returns {void}
4305 */
4306function ensureSelectionExists(setting, selector) {
4307 /** @type {HTMLSelectElement} */
4308 const selectElement = document.querySelector(selector);
4309 if (!selectElement) {
4310 return;
4311 }
4312 const options = Array.from(selectElement.options);
4313 const value = extension_settings.sd[setting];
4314 if (selectElement.selectedOptions.length && !options.some(option => option.value === value)) {
4315 extension_settings.sd[setting] = selectElement.selectedOptions[0].value;
4316 }
4317}
4318
4319/**
4320 * Generates an image based on the given trigger word.
4321 * @param {string} initiator The initiator of the image generation
4322 * @param {Record<string, object>} args Command arguments
4323 * @param {string} trigger Subject trigger word
4324 * @param {string} [message] Chat message
4325 * @param {function} [callback] Callback function
4326 * @returns {Promise<string|undefined>} Image path
4327 * @throws {Error} If the prompt or image generation fails
4328 */
4329async function generatePicture(initiator, args, trigger, message, callback) {
4330 if (!trigger || trigger.trim().length === 0) {
4331 console.log('Trigger word empty, aborting');
4332 return;
4333 }
4334
4335 if (!isValidState()) {
4336 toastr.warning('Image generation is not available. Check your settings and try again.');
4337 return;
4338 }
4339
4340 ensureSelectionExists('sampler', '#sd_sampler');
4341 ensureSelectionExists('model', '#sd_model');
4342
4343 trigger = trigger.trim();
4344 const generationType = getGenerationType(trigger);
4345 const generationTypeKey = Object.keys(generationMode).find(key => generationMode[key] === generationType);
4346 console.log(`Image generation mode ${generationTypeKey} triggered with "${trigger}"`);
4347
4348 const quietPrompt = getQuietPrompt(generationType, trigger);
4349 const context = getContext();
4350
4351 let characterName = context.groupId
4352 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
4353 : context.characters[context.characterId]?.name;
4354
4355 if (generationType === generationMode.BACKGROUND) {
4356 const callbackOriginal = callback;
4357 callback = async function (prompt, imagePath, generationType, _negativePromptPrefix, _initiator, prefixedPrompt, format) {
4358 const imgUrl = `url("${encodeURI(imagePath)}")`;
4359 await eventSource.emit(event_types.FORCE_SET_BACKGROUND, { url: imgUrl, path: imagePath });
4360
4361 if (typeof callbackOriginal === 'function') {
4362 await callbackOriginal(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt, format);
4363 } else {
4364 await sendMessage(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt, format);
4365 }
4366 };
4367 }
4368
4369 if (isTrueBoolean(args?.quiet)) {
4370 callback = () => { };
4371 }
4372
4373 if (isFalseBoolean(args?.gallery)) {
4374 characterName = '';
4375 }
4376
4377 const dimensions = setTypeSpecificDimensions(generationType);
4378 const abortController = new AbortController();
4379 let negativePromptPrefix = args?.negative || '';
4380 let imagePath = '';
4381
4382 const stopListener = () => abortController.abort('Aborted by user');
4383
4384 let loaderHandle = ActionLoaderHandle.EMPTY;
4385
4386 try {
4387 const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); };
4388
4389 // Each new generation picks its own reference image (swipes reuse the last one).
4390 pendingReferenceImage = null;
4391
4392 // generate the text prompt for the image
4393 let prompt = await getPrompt(generationType, message, trigger, quietPrompt, combineNegatives);
4394 console.log('Processed image prompt:', prompt);
4395
4396 // Extension hook for prompt processing
4397 const eventData = { prompt, generationType, message, trigger };
4398 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
4399 prompt = eventData.prompt; // Allow extensions to modify the prompt
4400
4401 if (typeof args?._abortController?.addEventListener === 'function') {
4402 args._abortController.addEventListener('abort', stopListener);
4403 }
4404
4405 // Show non-blocking stoppable toast for this generation
4406 loaderHandle = loader.show({
4407 blocking: false,
4408 slug: `${MODULE_NAME}-image-generation`,
4409 title: t`Image Generation`,
4410 message: t`Generating an image...`,
4411 onStop: stopListener,
4412 });
4413
4414 // generate the image
4415 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
4416 } catch (err) {
4417 // Check if this was an intentional abort by user
4418 if (abortController.signal.aborted) {
4419 console.log('SD: Image generation aborted by user');
4420 toastr.info('Image generation stopped.', 'Image Generation');
4421 return;
4422 }
4423
4424 console.trace(err);
4425 // errors here are most likely due to text generation failure
4426 // sendGenerationRequest mostly deals with its own errors
4427 const reason = err.error?.message || err.message || 'Unknown error';
4428 const errorText = 'SD prompt text generation failed. ' + reason;
4429 toastr.error(errorText, 'Image Generation');
4430 throw new Error(errorText);
4431 } finally {
4432 restoreOriginalDimensions(dimensions);
4433 await loaderHandle.hide();
4434 }
4435
4436 return imagePath;
4437}
4438
4439/**
4440 * Adjusts image generation dimensions based on the generation type and/or previous media attachment.
4441 * @param {number} generationType The type of image generation to perform, used to determine dimension adjustments
4442 * @param {MediaAttachment} [mediaAttachment] Media attachment to base dimension adjustments on
4443 * @returns {{height: number, width: number}} Previous dimensions before modification
4444 */
4445function setTypeSpecificDimensions(generationType, mediaAttachment = null) {
4446 const prevSDHeight = extension_settings.sd.height;
4447 const prevSDWidth = extension_settings.sd.width;
4448 const aspectRatio = extension_settings.sd.width / extension_settings.sd.height;
4449
4450 // 1. If there's a media attachment, match its previous dimensions
4451 // 2. Face images are always portrait (pun intended) - increase height if needed
4452 // 3. Background images are always landscape - increase width if needed
4453 if (Number.isInteger(mediaAttachment?.width) && Number.isInteger(mediaAttachment?.height)) {
4454 extension_settings.sd.width = mediaAttachment.width;
4455 extension_settings.sd.height = mediaAttachment.height;
4456 } else if ((generationType === generationMode.FACE || generationType === generationMode.FACE_MULTIMODAL) && aspectRatio >= 1) {
4457 // Round to nearest multiple of 64
4458 extension_settings.sd.height = Math.round(extension_settings.sd.width * 1.5 / 64) * 64;
4459 } else if (generationType === generationMode.BACKGROUND && aspectRatio <= 1) {
4460 // Round to nearest multiple of 64
4461 extension_settings.sd.width = Math.round(extension_settings.sd.height * 1.8 / 64) * 64;
4462 }
4463
4464 if (extension_settings.sd.snap) {
4465 // Force to use roughly the same pixel count as before rescaling
4466 const prevPixelCount = prevSDHeight * prevSDWidth;
4467 const newPixelCount = extension_settings.sd.height * extension_settings.sd.width;
4468
4469 if (prevPixelCount !== newPixelCount) {
4470 const ratio = Math.sqrt(prevPixelCount / newPixelCount);
4471 extension_settings.sd.height = Math.round(extension_settings.sd.height * ratio / 64) * 64;
4472 extension_settings.sd.width = Math.round(extension_settings.sd.width * ratio / 64) * 64;
4473 console.log(`Pixel counts after rescaling: ${prevPixelCount} -> ${newPixelCount} (ratio: ${ratio})`);
4474
4475 const resolution = resolutionOptions[getClosestKnownResolution()];
4476 if (resolution) {
4477 extension_settings.sd.height = resolution.height;
4478 extension_settings.sd.width = resolution.width;
4479 console.log('Snap to resolution', JSON.stringify(resolution));
4480 } else {
4481 console.warn('Snap to resolution failed, using custom dimensions');
4482 }
4483 }
4484 }
4485
4486 return { height: prevSDHeight, width: prevSDWidth };
4487}
4488
4489/**
4490 * Restores the original image generation dimensions after generation is complete.
4491 * @param {{height: number, width: number}} savedParams The original dimensions to restore
4492 */
4493function restoreOriginalDimensions(savedParams) {
4494 extension_settings.sd.height = savedParams.height;
4495 extension_settings.sd.width = savedParams.width;
4496}
4497
4498/**
4499 * Generates a prompt for image generation.
4500 * @param {number} generationType The type of image generation to perform.
4501 * @param {string} message A message text to use for the image generation.
4502 * @param {string} trigger A trigger string to use for the image generation.
4503 * @param {string} quietPrompt A quiet prompt to use for the image generation.
4504 * @param {function} combineNegatives A function that combines the negative prompt with other prompts.
4505 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
4506 */
4507async function getPrompt(generationType, message, trigger, quietPrompt, combineNegatives) {
4508 let prompt;
4509 console.log('getPrompt: Generation mode', generationType, 'triggered with', trigger);
4510 switch (generationType) {
4511 case generationMode.RAW_LAST:
4512 prompt = message || getRawLastMessage();
4513 break;
4514 case generationMode.FREE:
4515 prompt = generateFreeModePrompt(trigger.trim(), combineNegatives);
4516 break;
4517 case generationMode.FACE_MULTIMODAL:
4518 case generationMode.CHARACTER_MULTIMODAL:
4519 case generationMode.USER_MULTIMODAL:
4520 prompt = await generateMultimodalPrompt(generationType, quietPrompt);
4521 break;
4522 default:
4523 prompt = await generatePrompt(quietPrompt);
4524 break;
4525 }
4526
4527 if (generationType === generationMode.FREE_EXTENDED) {
4528 prompt = generateFreeModePrompt(prompt.trim(), combineNegatives);
4529 }
4530
4531 if (generationType !== generationMode.FREE) {
4532 prompt = await refinePrompt(prompt);
4533 }
4534
4535 return prompt;
4536}
4537
4538/**
4539 * Generates a free prompt with a character-specific prompt prefix support.
4540 * @param {string} trigger - The prompt to use for the image generation.
4541 * @param {function} combineNegatives - A function that combines the negative prompt with other prompts.
4542 * @returns {string}
4543 */
4544function generateFreeModePrompt(trigger, combineNegatives) {
4545 return trigger
4546 .replace(/^char(\s|,)|{{charPrefix}}/gi, (_, suffix) => {
4547 const getLastCharacterKey = () => {
4548 if (typeof this_chid !== 'undefined') {
4549 return getCharaFilename(this_chid);
4550 }
4551 const context = getContext();
4552 for (let i = context.chat.length - 1; i >= 0; i--) {
4553 const message = context.chat[i];
4554 if (!message.is_user && !message.is_system && typeof message.original_avatar === 'string') {
4555 return message.original_avatar.replace(/\.[^/.]+$/, '');
4556 }
4557 }
4558 return '';
4559 };
4560
4561 const key = getLastCharacterKey();
4562 const value = (extension_settings.sd.character_prompts[key] || '').trim();
4563 const negativeValue = (extension_settings.sd.character_negative_prompts[key] || '').trim();
4564 typeof combineNegatives === 'function' && negativeValue ? combineNegatives(negativeValue) : void 0;
4565 return value ? combinePrefixes(value, (suffix || '')) : '';
4566 });
4567}
4568
4569/**
4570 * Generates a prompt using multimodal captioning.
4571 * @param {number} generationType - The type of image generation to perform.
4572 * @param {string} quietPrompt - The prompt to use for the image generation.
4573 */
4574async function generateMultimodalPrompt(generationType, quietPrompt) {
4575 let avatarUrl;
4576
4577 if (generationType === generationMode.USER_MULTIMODAL) {
4578 avatarUrl = getUserAvatarUrl();
4579 }
4580
4581 if (generationType === generationMode.CHARACTER_MULTIMODAL || generationType === generationMode.FACE_MULTIMODAL) {
4582 avatarUrl = getCharacterAvatarUrl();
4583 }
4584
4585 try {
4586 const toast = toastr.info('Generating multimodal caption...', 'Image Generation');
4587 const response = await fetch(avatarUrl);
4588
4589 if (!response.ok) {
4590 throw new Error('Could not fetch avatar image.');
4591 }
4592
4593 const avatarBlob = await response.blob();
4594 const avatarBase64 = await getBase64Async(avatarBlob);
4595
4596 const caption = await getMultimodalCaption(avatarBase64, quietPrompt);
4597 toastr.clear(toast);
4598
4599 if (!caption) {
4600 throw new Error('No caption returned from the API.');
4601 }
4602
4603 return caption;
4604 } catch (error) {
4605 console.error(error);
4606 toastr.error('Multimodal captioning failed. Please try again.', 'Image Generation');
4607 throw new Error('Multimodal captioning failed.');
4608 }
4609}
4610
4611function getCharacterAvatarUrl() {
4612 const context = getContext();
4613
4614 if (context.groupId) {
4615 const groupMembers = context.groups.find(x => x.id === context.groupId)?.members;
4616 const lastMessageAvatar = context.chat?.filter(x => !x.is_system && !x.is_user)?.slice(-1)[0]?.original_avatar;
4617 const randomMemberAvatar = Array.isArray(groupMembers) ? groupMembers[Math.floor(Math.random() * groupMembers.length)] : null;
4618 const avatarToUse = lastMessageAvatar || randomMemberAvatar;
4619 return formatCharacterAvatar(avatarToUse);
4620 } else {
4621 return getCharacterAvatar(context.characterId);
4622 }
4623}
4624
4625function getUserAvatarUrl() {
4626 return getUserAvatar(user_avatar);
4627}
4628
4629/**
4630 * Generates a prompt using the main LLM API.
4631 * @param {string} quietPrompt - The prompt to use for the image generation.
4632 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
4633 */
4634async function generatePrompt(quietPrompt) {
4635 const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');
4636 const profileId = extension_settings.sd.prompt_generation_profile;
4637 let reply;
4638
4639 // When the workflow uses a reference image and there is more than one to choose
4640 // from, have the same LLM request return the selection along with the prompt.
4641 const refCandidates = await getEligibleReferenceImages();
4642 if (refCandidates.length === 1) {
4643 pendingReferenceImage = refCandidates[0];
4644 }
4645 const combineReferenceSelection = refCandidates.length > 1;
4646 const effectiveQuietPrompt = combineReferenceSelection
4647 ? quietPrompt + '\n' + buildReferenceSelectionAddendum(refCandidates)
4648 : quietPrompt;
4649
4650 try {
4651 reply = profileId
4652 ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt: effectiveQuietPrompt }))
4653 : await generateQuietPrompt({ quietPrompt: effectiveQuietPrompt });
4654 } finally {
4655 toastr.clear(toast);
4656 }
4657
4658 if (combineReferenceSelection) {
4659 const { cleaned, selected } = extractReferenceSelection(String(reply ?? ''), refCandidates);
4660 reply = cleaned;
4661 // No/invalid selection -> leave unset; the workflow builder retries with a dedicated call.
4662 pendingReferenceImage = selected;
4663 console.log('SD: reference image selected with the image prompt:', selected?.tag ?? '(none)');
4664 }
4665
4666 const processedReply = processReply(reply);
4667
4668 if (!processedReply) {
4669 toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');
4670 throw new Error('Prompt generation failed.');
4671 }
4672
4673 return processedReply;
4674}
4675
4676/**
4677 * Runs a callback with a specific Connection Manager profile temporarily active,
4678 * then restores the previously active profile. This lets the image prompt be
4679 * generated by the chosen LLM *with the full chat context* (via generateQuietPrompt),
4680 * instead of a context-free one-off request.
4681 *
4682 * The switch is only performed when a real connection profile is currently active
4683 * (so it can be reliably restored). When no profile is active — i.e. the user drives
4684 * the API panel manually — switching to a profile could not be undone without
4685 * clobbering those manual settings, so we leave the active model in place and warn once.
4686 *
4687 * @param {string} targetProfileId Profile to activate for the duration of the callback.
4688 * @param {() => Promise<any>} callback Work to run while the target profile is active.
4689 * @returns {Promise<any>} The callback's result.
4690 */
4691async function withConnectionProfile(targetProfileId, callback) {
4692 const select = /** @type {HTMLSelectElement} */ (document.getElementById('connection_profiles'));
4693 const connectionManager = extension_settings.connectionManager;
4694 const currentProfileId = connectionManager?.selectedProfile;
4695
4696 const canSwitch = !!select
4697 && !!connectionManager
4698 && Array.isArray(connectionManager.profiles)
4699 && connectionManager.profiles.some(p => p.id === targetProfileId)
4700 && Array.from(select.options).some(o => o.value === targetProfileId)
4701 && !!currentProfileId // a real profile is active, so it can be restored afterwards
4702 && currentProfileId !== targetProfileId;
4703
4704 if (!canSwitch) {
4705 // Selected but no base profile to restore from -> use the active model and warn once.
4706 if (targetProfileId && !currentProfileId && !promptProfileWarnedNoBaseProfile) {
4707 promptProfileWarnedNoBaseProfile = true;
4708 toastr.info('The image-prompt LLM profile is only applied while a connection profile is active (so the original can be restored). Using the current model.', 'Image Generation');
4709 }
4710 return await callback();
4711 }
4712
4713 const switchToProfile = async (profileId) => {
4714 const loaded = new Promise(resolve => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
4715 const index = Array.from(select.options).findIndex(o => o.value === profileId);
4716 select.selectedIndex = index >= 0 ? index : 0;
4717 select.dispatchEvent(new Event('change'));
4718 // Wait for the profile's commands to finish applying (don't hang forever if the event never fires).
4719 await Promise.race([loaded, delay(10000)]);
4720 // Applying a profile reconnects the API, which is asynchronous. Generating before the
4721 // connection is re-established fails instantly, so wait for it to come back up
4722 // (mirrors the built-in /profile command). rejectOnTimeout:false -> proceed anyway after the timeout.
4723 await waitUntilCondition(() => online_status !== 'no_connection', 10000, 100, { rejectOnTimeout: false });
4724 };
4725
4726 // Background image-prompt work must not disturb foreground features that follow the user's
4727 // active connection profile (notably text prompt-cache keepalives).
4728 await eventSource.emit(event_types.CONNECTION_PROFILE_TEMPORARY_STARTED);
4729 try {
4730 await switchToProfile(targetProfileId);
4731 return await callback();
4732 } finally {
4733 try {
4734 await switchToProfile(currentProfileId);
4735 } catch (err) {
4736 console.error('SD: failed to restore the previous connection profile after image-prompt generation', err);
4737 } finally {
4738 await eventSource.emit(event_types.CONNECTION_PROFILE_TEMPORARY_ENDED);
4739 }
4740 }
4741}
4742
4743/**
4744 * Sends a request to image generation endpoint and processes the result.
4745 * @param {number} generationType Type of image generation
4746 * @param {string} prompt Prompt to be used for image generation
4747 * @param {string} additionalNegativePrefix Additional negative prompt to be used for image generation
4748 * @param {string} characterName Name of the character
4749 * @param {function} callback Callback function to be called after image generation
4750 * @param {string} initiator The initiator of the image generation
4751 * @param {AbortSignal} signal Abort signal to cancel the request
4752 * @returns
4753 */
4754async function sendGenerationRequest(generationType, prompt, additionalNegativePrefix, characterName, callback, initiator, signal) {
4755 const noCharPrefix = [generationMode.FREE, generationMode.BACKGROUND, generationMode.USER, generationMode.USER_MULTIMODAL, generationMode.FREE_EXTENDED];
4756 const isCharChat = this_chid !== undefined && !selected_group;
4757 const ignoreNoCharForSwipe = initiator === initiators.swipe && isCharChat;
4758
4759 const skipCharPrefix = !ignoreNoCharForSwipe && noCharPrefix.includes(generationType);
4760
4761 /**
4762 * Performs a single image generation attempt against the live extension_settings.sd config.
4763 * Reads settings live so it can be retried after applying a fallback preset.
4764 * @param {AbortSignal} attemptSignal Abort signal to cancel the request.
4765 * @returns {Promise<{result: {format: string, data: string}, prefixedPrompt: string}>}
4766 * @throws {Error} On failure or when the endpoint returns no image data.
4767 */
4768 async function attemptImageGeneration(attemptSignal) {
4769 const prefix = skipCharPrefix
4770 ? extension_settings.sd.prompt_prefix
4771 : combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix());
4772
4773 const negativePrefix = skipCharPrefix
4774 ? extension_settings.sd.negative_prompt
4775 : combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix());
4776
4777 const prefixedPrompt = substituteParams(combinePrefixes(prefix, prompt, '{prompt}'));
4778 const negativePrompt = substituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix));
4779
4780 let result = { format: '', data: '' };
4781 switch (extension_settings.sd.source) {
4782 case sources.extras:
4783 result = await generateExtrasImage(prefixedPrompt, negativePrompt, attemptSignal);
4784 break;
4785 case sources.horde:
4786 result = await generateHordeImage(prefixedPrompt, negativePrompt, attemptSignal);
4787 break;
4788 case sources.vlad:
4789 result = await generateAutoImage(prefixedPrompt, negativePrompt, attemptSignal);
4790 break;
4791 case sources.drawthings:
4792 result = await generateDrawthingsImage(prefixedPrompt, negativePrompt, attemptSignal);
4793 break;
4794 case sources.auto:
4795 result = await generateAutoImage(prefixedPrompt, negativePrompt, attemptSignal);
4796 break;
4797 case sources.sdcpp:
4798 result = await generateSdcppImage(prefixedPrompt, negativePrompt, attemptSignal);
4799 break;
4800 case sources.novel:
4801 result = await generateNovelImage(prefixedPrompt, negativePrompt, attemptSignal);
4802 break;
4803 case sources.openai:
4804 result = await generateOpenAiImage(prefixedPrompt, attemptSignal);
4805 break;
4806 case sources.aimlapi:
4807 result = await generateAimlapiImage(prefixedPrompt, attemptSignal);
4808 break;
4809 case sources.comfy:
4810 switch (extension_settings.sd.comfy_type) {
4811 case comfyTypes.runpod_serverless:
4812 result = await generateComfyRunPodImage(prefixedPrompt, negativePrompt, attemptSignal);
4813 break;
4814 case comfyTypes.standard:
4815 result = await generateComfyImage(prefixedPrompt, negativePrompt, attemptSignal);
4816 break;
4817 default:
4818 throw new Error('Unknown comfyUI server type.');
4819 }
4820 break;
4821 case sources.togetherai:
4822 result = await generateTogetherAIImage(prefixedPrompt, negativePrompt, attemptSignal);
4823 break;
4824 case sources.pollinations:
4825 result = await generatePollinationsImage(prefixedPrompt, negativePrompt, attemptSignal);
4826 break;
4827 case sources.stability:
4828 result = await generateStabilityImage(prefixedPrompt, negativePrompt, attemptSignal);
4829 break;
4830 case sources.huggingface:
4831 result = await generateHuggingFaceImage(prefixedPrompt, attemptSignal);
4832 break;
4833 case sources.chutes:
4834 result = await generateChutesImage(prefixedPrompt, negativePrompt, attemptSignal);
4835 break;
4836 case sources.electronhub:
4837 result = await generateElectronHubImage(prefixedPrompt, attemptSignal);
4838 break;
4839 case sources.nanogpt:
4840 result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, attemptSignal);
4841 break;
4842 case sources.bfl:
4843 result = await generateBflImage(prefixedPrompt, attemptSignal);
4844 break;
4845 case sources.falai:
4846 result = await generateFalaiImage(prefixedPrompt, negativePrompt, attemptSignal);
4847 break;
4848 case sources.xai:
4849 result = await generateXAIImage(prefixedPrompt, negativePrompt, attemptSignal);
4850 break;
4851 case sources.google:
4852 result = await generateGoogleImage(prefixedPrompt, negativePrompt, attemptSignal);
4853 break;
4854 case sources.zai:
4855 result = await generateZaiImage(prefixedPrompt, attemptSignal);
4856 break;
4857 case sources.openrouter:
4858 result = await generateOpenRouterImage(prefixedPrompt, attemptSignal);
4859 break;
4860 case sources.workersai:
4861 result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, attemptSignal);
4862 break;
4863 }
4864
4865 if (!result.data) {
4866 throw new Error('Endpoint did not return image data.');
4867 }
4868
4869 return { result, prefixedPrompt };
4870 }
4871
4872 const currentChatId = getCurrentChatId();
4873 const fallbackChain = extension_settings.sd.settings_fallback_enabled ? getConfiguredPresetChain() : [];
4874 let genOutput;
4875
4876 if (fallbackChain.length > 0) {
4877 // Chain mode: try every preset in order. Locally-hosted backends are probed
4878 // first so a powered-off server is skipped after ~1.5s instead of stalling
4879 // the attempt. The live settings are restored afterward either way.
4880 const restore = snapshotSdSettings();
4881 let lastError = new Error('No provider in the fallback chain was reachable.');
4882 try {
4883 for (const entry of fallbackChain) {
4884 applySdSettingsSnapshot(entry.preset);
4885
4886 if (!(await isCurrentSourceReachable())) {
4887 console.warn(`SD: chain entry "${entry.name}" is not reachable, skipping`);
4888 toastr.warning(`Provider "${entry.name}" is not reachable, trying the next one…`, 'Image Generation');
4889 continue;
4890 }
4891
4892 try {
4893 genOutput = await attemptImageGeneration(signal);
4894 break;
4895 } catch (err) {
4896 if (signal?.aborted) {
4897 console.log('SD: Image generation aborted by user');
4898 toastr.info('Image generation stopped.', 'Image Generation');
4899 return;
4900 }
4901 lastError = err;
4902 console.error(`SD: generation with chain entry "${entry.name}" failed`, err);
4903 toastr.warning(`Provider "${entry.name}" failed, trying the next one…`, 'Image Generation');
4904 }
4905 }
4906 } finally {
4907 applySdSettingsSnapshot(restore);
4908 }
4909
4910 if (!genOutput) {
4911 toastr.error('Image generation failed for every provider in the fallback chain.' + '\n\n' + String(lastError), 'Image Generation');
4912 return;
4913 }
4914 } else {
4915 try {
4916 genOutput = await attemptImageGeneration(signal);
4917 } catch (err) {
4918 // Check if this was an intentional abort by user
4919 if (signal?.aborted) {
4920 console.log('SD: Image generation aborted by user');
4921 toastr.info('Image generation stopped.', 'Image Generation');
4922 return;
4923 }
4924
4925 console.error('Image generation request error: ', err);
4926 toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
4927 return;
4928 }
4929 }
4930
4931 const { result, prefixedPrompt } = genOutput;
4932
4933 if (currentChatId !== getCurrentChatId()) {
4934 console.warn('Chat changed, aborting SD result saving');
4935 toastr.warning('Chat changed, generated image discarded.', 'Image Generation');
4936 return;
4937 }
4938
4939 const filename = characterName ? `${characterName}_${humanizedDateTime()}` : humanizedDateTime();
4940 const base64Image = await saveBase64AsFile(result.data, characterName, filename, result.format);
4941 callback
4942 ? await callback(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format)
4943 : await sendMessage(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format);
4944 return base64Image;
4945}
4946
4947/**
4948 * Generates an image using the TogetherAI API.
4949 * @param {string} prompt - The main instruction used to guide the image generation.
4950 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4951 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4952 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4953 */
4954async function generateTogetherAIImage(prompt, negativePrompt, signal) {
4955 const result = await fetch('/api/sd/together/generate', {
4956 method: 'POST',
4957 headers: getRequestHeaders(),
4958 signal: signal,
4959 body: JSON.stringify({
4960 prompt: prompt,
4961 negative_prompt: negativePrompt,
4962 model: extension_settings.sd.model,
4963 steps: extension_settings.sd.steps,
4964 width: extension_settings.sd.width,
4965 height: extension_settings.sd.height,
4966 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4967 }),
4968 });
4969
4970 if (result.ok) {
4971 return await result.json();
4972 } else {
4973 const text = await result.text();
4974 throw new Error(text);
4975 }
4976}
4977
4978/**
4979 * Generates an image using the Pollinations API.
4980 * @param {string} prompt - The main instruction used to guide the image generation.
4981 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4982 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4983 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4984 */
4985async function generatePollinationsImage(prompt, negativePrompt, signal) {
4986 const result = await fetch('/api/sd/pollinations/generate', {
4987 method: 'POST',
4988 headers: getRequestHeaders(),
4989 signal: signal,
4990 body: JSON.stringify({
4991 prompt: prompt,
4992 negative_prompt: negativePrompt,
4993 model: extension_settings.sd.model,
4994 width: extension_settings.sd.width,
4995 height: extension_settings.sd.height,
4996 enhance: extension_settings.sd.pollinations_enhance,
4997 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4998 }),
4999 });
5000
5001 if (result.ok) {
5002 const data = await result.json();
5003 return { format: data?.format, data: data?.image };
5004 } else {
5005 const text = await result.text();
5006 throw new Error(text);
5007 }
5008}
5009
5010/**
5011 * Generates an "extras" image using a provided prompt and other settings.
5012 *
5013 * @param {string} prompt - The main instruction used to guide the image generation.
5014 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5015 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5016 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5017 */
5018async function generateExtrasImage(prompt, negativePrompt, signal) {
5019 const url = new URL(getApiUrl());
5020 url.pathname = '/api/image';
5021 const result = await doExtrasFetch(url, {
5022 method: 'POST',
5023 headers: {
5024 'Content-Type': 'application/json',
5025 },
5026 signal: signal,
5027 body: JSON.stringify({
5028 prompt: prompt,
5029 sampler: extension_settings.sd.sampler,
5030 steps: extension_settings.sd.steps,
5031 scale: extension_settings.sd.scale,
5032 width: extension_settings.sd.width,
5033 height: extension_settings.sd.height,
5034 negative_prompt: negativePrompt,
5035 restore_faces: !!extension_settings.sd.restore_faces,
5036 enable_hr: !!extension_settings.sd.enable_hr,
5037 karras: !!extension_settings.sd.horde_karras,
5038 hr_upscaler: extension_settings.sd.hr_upscaler,
5039 hr_scale: extension_settings.sd.hr_scale,
5040 denoising_strength: extension_settings.sd.denoising_strength,
5041 hr_second_pass_steps: extension_settings.sd.hr_second_pass_steps,
5042 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5043 }),
5044 });
5045
5046 if (result.ok) {
5047 const data = await result.json();
5048 return { format: 'jpg', data: data.image };
5049 } else {
5050 const text = await result.text();
5051 throw new Error(text);
5052 }
5053}
5054
5055/**
5056 * Gets an aspect ratio for Stability that is the closest to the given width and height.
5057 * @param {number} width Target width
5058 * @param {number} height Target height
5059 * @param {'google'|'stability'|'zai'|'xai'} source Source of the request, used to determine aspect ratio
5060 * @returns {string} Closest aspect ratio as a string
5061 */
5062function getClosestAspectRatio(width, height, source) {
5063 function getAspectRatios() {
5064 switch (source) {
5065 case 'stability':
5066 return {
5067 '16:9': 16 / 9,
5068 '1:1': 1,
5069 '21:9': 21 / 9,
5070 '2:3': 2 / 3,
5071 '3:2': 3 / 2,
5072 '4:5': 4 / 5,
5073 '5:4': 5 / 4,
5074 '9:16': 9 / 16,
5075 '9:21': 9 / 21,
5076 };
5077 case 'google':
5078 return {
5079 '1:1': 1,
5080 '16:9': 16 / 9,
5081 '9:16': 9 / 16,
5082 '4:3': 4 / 3,
5083 '3:4': 3 / 4,
5084 };
5085 case 'zai':
5086 return {
5087 '1:1': 1,
5088 '16:9': 16 / 9,
5089 '9:16': 9 / 16,
5090 };
5091 case 'xai':
5092 return {
5093 '1:1': 1,
5094 '3:4': 3 / 4,
5095 '4:3': 4 / 3,
5096 '9:16': 9 / 16,
5097 '16:9': 16 / 9,
5098 '2:3': 2 / 3,
5099 '3:2': 3 / 2,
5100 '9:19.5': 9 / 19.5,
5101 '19.5:9': 19.5 / 9,
5102 '9:20': 9 / 20,
5103 '20:9': 20 / 9,
5104 '1:2': 1 / 2,
5105 '2:1': 2 / 1,
5106 };
5107 default:
5108 console.warn(`Unknown source "${source}" for aspect ratio calculation.`);
5109 return null;
5110 }
5111 }
5112
5113 const aspectRatios = getAspectRatios() || { '1:1': 1 };
5114
5115 const aspectRatio = width / height;
5116
5117 let closestAspectRatio = Object.keys(aspectRatios)[0];
5118 let minDiff = Math.abs(aspectRatio - aspectRatios[closestAspectRatio]);
5119
5120 for (const key in aspectRatios) {
5121 const diff = Math.abs(aspectRatio - aspectRatios[key]);
5122 if (diff < minDiff) {
5123 minDiff = diff;
5124 closestAspectRatio = key;
5125 }
5126 }
5127
5128 return closestAspectRatio;
5129}
5130
5131/**
5132 * Get closest size for Electron Hub
5133 * @param {number} width - The width of the image
5134 * @param {number} height - The height of the image
5135 * @param {string[]} sizes - Available sizes
5136 * @returns {Promise<string>} - The closest size
5137 */
5138async function getClosestSize(width, height, sizes = []) {
5139 const sizesData = [];
5140
5141 if (Array.isArray(sizes) && sizes.length > 0) {
5142 sizesData.push(...sizes);
5143 } else if (extension_settings.sd.source === sources.electronhub) {
5144 const response = await fetch('/api/sd/electronhub/sizes', {
5145 method: 'POST',
5146 headers: getRequestHeaders(),
5147 body: JSON.stringify({
5148 model: extension_settings.sd.model,
5149 }),
5150 });
5151 if (!response.ok) {
5152 const text = await response.text();
5153 throw new Error(text);
5154 }
5155 const result = await response.json();
5156 sizesData.push(...result.sizes);
5157 } else {
5158 return null;
5159 }
5160
5161 const targetWidth = Number(width);
5162 const targetHeight = Number(height);
5163
5164 if (isNaN(targetWidth) || isNaN(targetHeight)) {
5165 return null;
5166 }
5167
5168 const targetAspect = targetWidth / targetHeight;
5169 const targetResolution = targetWidth * targetHeight;
5170
5171 const closestSize = sizesData.reduce((closest, size) => {
5172 if (!size || typeof size !== 'string') {
5173 return closest;
5174 }
5175 const sizeParts = size.split('x');
5176 if (sizeParts.length !== 2) {
5177 return closest;
5178 }
5179
5180 const sizeWidth = Number(sizeParts[0]);
5181 const sizeHeight = Number(sizeParts[1]);
5182
5183 if (isNaN(sizeWidth) || isNaN(sizeHeight)) {
5184 return closest;
5185 }
5186
5187 const aspectDiff = Math.abs((sizeWidth / sizeHeight) - targetAspect) / targetAspect;
5188 const resolutionDiff = Math.abs(sizeWidth * sizeHeight - targetResolution) / targetResolution;
5189 const diff = aspectDiff + resolutionDiff;
5190
5191 return diff < closest.diff ? { size, diff } : closest;
5192 }, { size: null, diff: Infinity });
5193
5194 const size = closestSize.size;
5195 return size;
5196}
5197
5198/**
5199 * Generates an image using Stability AI.
5200 * @param {string} prompt - The main instruction used to guide the image generation.
5201 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5202 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5203 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5204 */
5205async function generateStabilityImage(prompt, negativePrompt, signal) {
5206 const IMAGE_FORMAT = 'png';
5207 const PROMPT_LIMIT = 10000;
5208
5209 try {
5210 const response = await fetch('/api/sd/stability/generate', {
5211 method: 'POST',
5212 headers: getRequestHeaders(),
5213 signal: signal,
5214 body: JSON.stringify({
5215 model: extension_settings.sd.model,
5216 payload: {
5217 prompt: prompt.slice(0, PROMPT_LIMIT),
5218 negative_prompt: negativePrompt.slice(0, PROMPT_LIMIT),
5219 aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'stability'),
5220 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5221 style_preset: extension_settings.sd.stability_style_preset,
5222 output_format: IMAGE_FORMAT,
5223 },
5224 }),
5225 });
5226
5227 if (!response.ok) {
5228 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
5229 }
5230
5231 const base64Image = await response.text();
5232
5233 return {
5234 format: IMAGE_FORMAT,
5235 data: base64Image,
5236 };
5237 } catch (error) {
5238 console.error('Error generating image with Stability AI:', error);
5239 throw error;
5240 }
5241}
5242
5243/**
5244 * Generates a "horde" image using the provided prompt and configuration settings.
5245 *
5246 * @param {string} prompt - The main instruction used to guide the image generation.
5247 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5248 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5249 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5250 */
5251async function generateHordeImage(prompt, negativePrompt, signal) {
5252 const result = await fetch('/api/horde/generate-image', {
5253 method: 'POST',
5254 headers: getRequestHeaders(),
5255 signal: signal,
5256 body: JSON.stringify({
5257 prompt: prompt,
5258 sampler: extension_settings.sd.sampler,
5259 steps: extension_settings.sd.steps,
5260 scale: extension_settings.sd.scale,
5261 width: extension_settings.sd.width,
5262 height: extension_settings.sd.height,
5263 negative_prompt: negativePrompt,
5264 model: extension_settings.sd.model,
5265 nsfw: extension_settings.sd.horde_nsfw,
5266 restore_faces: !!extension_settings.sd.restore_faces,
5267 enable_hr: !!extension_settings.sd.enable_hr,
5268 sanitize: !!extension_settings.sd.horde_sanitize,
5269 clip_skip: extension_settings.sd.clip_skip,
5270 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5271 }),
5272 });
5273
5274 if (result.ok) {
5275 const data = await result.text();
5276 return { format: 'webp', data: data };
5277 } else {
5278 const text = await result.text();
5279 throw new Error(text);
5280 }
5281}
5282
5283/**
5284 * Generates an image in SD WebUI API using the provided prompt and configuration settings.
5285 *
5286 * @param {string} prompt - The main instruction used to guide the image generation.
5287 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5288 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5289 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5290 */
5291async function generateAutoImage(prompt, negativePrompt, signal) {
5292 const isValidVae = extension_settings.sd.vae && !['N/A', placeholderVae].includes(extension_settings.sd.vae);
5293 let payload = {
5294 ...getSdRequestBody(),
5295 prompt: prompt,
5296 negative_prompt: negativePrompt,
5297 sampler_name: extension_settings.sd.sampler,
5298 scheduler: extension_settings.sd.scheduler,
5299 steps: extension_settings.sd.steps,
5300 cfg_scale: extension_settings.sd.scale,
5301 width: extension_settings.sd.width,
5302 height: extension_settings.sd.height,
5303 restore_faces: !!extension_settings.sd.restore_faces,
5304 enable_hr: !!extension_settings.sd.enable_hr,
5305 hr_upscaler: extension_settings.sd.hr_upscaler,
5306 hr_scale: extension_settings.sd.hr_scale,
5307 hr_additional_modules: [],
5308 denoising_strength: extension_settings.sd.denoising_strength,
5309 hr_second_pass_steps: extension_settings.sd.hr_second_pass_steps,
5310 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5311 override_settings: {
5312 CLIP_stop_at_last_layers: extension_settings.sd.clip_skip,
5313 sd_vae: isValidVae ? extension_settings.sd.vae : undefined,
5314 forge_additional_modules: isValidVae ? [extension_settings.sd.vae] : undefined, // For SD Forge
5315 },
5316 override_settings_restore_afterwards: true,
5317 clip_skip: extension_settings.sd.clip_skip, // For SD.Next
5318 save_images: true,
5319 send_images: true,
5320 do_not_save_grid: false,
5321 do_not_save_samples: false,
5322 };
5323
5324 // Conditionally add the ADetailer if adetailer_face is enabled
5325 if (extension_settings.sd.adetailer_face) {
5326 payload = deepMerge(payload, {
5327 alwayson_scripts: {
5328 ADetailer: {
5329 args: [
5330 true, // ad_enable
5331 true, // skip_img2img
5332 {
5333 'ad_model': 'face_yolov8n.pt',
5334 },
5335 ],
5336 },
5337 },
5338 });
5339 }
5340
5341 // Make the fetch call with the payload
5342 const result = await fetch('/api/sd/generate', {
5343 method: 'POST',
5344 headers: getRequestHeaders(),
5345 signal: signal,
5346 body: JSON.stringify(payload),
5347 });
5348
5349 if (result.ok) {
5350 const data = await result.json();
5351 return { format: 'png', data: data.images[0] };
5352 } else {
5353 const text = await result.text();
5354 throw new Error(text);
5355 }
5356}
5357
5358/**
5359 * Generates an image using stable-diffusion.cpp server API.
5360 *
5361 * @param {string} prompt - The main instruction used to guide the image generation.
5362 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5363 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5364 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5365 */
5366async function generateSdcppImage(prompt, negativePrompt, signal) {
5367 const payload = {
5368 url: extension_settings.sd.sdcpp_url,
5369 model: extension_settings.sd.model || undefined,
5370 prompt: prompt,
5371 negative_prompt: negativePrompt,
5372 steps: extension_settings.sd.steps,
5373 cfg_scale: extension_settings.sd.scale,
5374 width: extension_settings.sd.width,
5375 height: extension_settings.sd.height,
5376 batch_size: 1,
5377 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5378 };
5379
5380 if (extension_settings.sd.sampler && extension_settings.sd.sampler !== 'N/A') {
5381 payload.sampler_name = extension_settings.sd.sampler;
5382 }
5383
5384 if (extension_settings.sd.scheduler && extension_settings.sd.scheduler !== 'N/A') {
5385 payload.scheduler = extension_settings.sd.scheduler;
5386 }
5387
5388 if (Number.isFinite(extension_settings.sd.clip_skip)) {
5389 payload.clip_skip = extension_settings.sd.clip_skip;
5390 }
5391
5392 const result = await fetch('/api/sd/sdcpp/generate', {
5393 method: 'POST',
5394 headers: getRequestHeaders(),
5395 signal: signal,
5396 body: JSON.stringify(payload),
5397 });
5398
5399 if (result.ok) {
5400 const data = await result.json();
5401 return { format: 'png', data: data.images?.[0] };
5402 } else {
5403 const text = await result.text();
5404 throw new Error(text);
5405 }
5406}
5407
5408/**
5409 * Generates an image in Drawthings API using the provided prompt and configuration settings.
5410 *
5411 * @param {string} prompt - The main instruction used to guide the image generation.
5412 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5413 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5414 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5415 */
5416async function generateDrawthingsImage(prompt, negativePrompt, signal) {
5417 const result = await fetch('/api/sd/drawthings/generate', {
5418 method: 'POST',
5419 headers: getRequestHeaders(),
5420 signal: signal,
5421 body: JSON.stringify({
5422 ...getSdRequestBody(),
5423 prompt: prompt,
5424 negative_prompt: negativePrompt,
5425 sampler_name: extension_settings.sd.sampler,
5426 steps: extension_settings.sd.steps,
5427 cfg_scale: extension_settings.sd.scale,
5428 width: extension_settings.sd.width,
5429 height: extension_settings.sd.height,
5430 restore_faces: !!extension_settings.sd.restore_faces,
5431 enable_hr: !!extension_settings.sd.enable_hr,
5432 denoising_strength: extension_settings.sd.denoising_strength,
5433 clip_skip: extension_settings.sd.clip_skip,
5434 upscaler_scale: extension_settings.sd.hr_scale,
5435 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5436 // TODO: advanced API parameters: hr, upscaler
5437 }),
5438 });
5439
5440 if (result.ok) {
5441 const data = await result.json();
5442 return { format: 'png', data: data.images[0] };
5443 } else {
5444 const text = await result.text();
5445 throw new Error(text);
5446 }
5447}
5448
5449/**
5450 * Generates an image in NovelAI API using the provided prompt and configuration settings.
5451 *
5452 * @param {string} prompt - The main instruction used to guide the image generation.
5453 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5454 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5455 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5456 */
5457async function generateNovelImage(prompt, negativePrompt, signal) {
5458 const { steps, width, height, sm, sm_dyn } = getNovelParams();
5459
5460 const result = await fetch('/api/novelai/generate-image', {
5461 method: 'POST',
5462 headers: getRequestHeaders(),
5463 signal: signal,
5464 body: JSON.stringify({
5465 prompt: prompt,
5466 model: extension_settings.sd.model,
5467 sampler: extension_settings.sd.sampler,
5468 scheduler: extension_settings.sd.scheduler,
5469 steps: steps,
5470 scale: extension_settings.sd.scale,
5471 width: width,
5472 height: height,
5473 negative_prompt: negativePrompt,
5474 upscale_ratio: extension_settings.sd.hr_scale,
5475 decrisper: extension_settings.sd.novel_decrisper,
5476 variety_boost: extension_settings.sd.novel_variety_boost,
5477 sm: sm,
5478 sm_dyn: sm_dyn,
5479 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5480 }),
5481 });
5482
5483 if (result.ok) {
5484 const data = await result.text();
5485 return { format: 'png', data: data };
5486 } else {
5487 const text = await result.text();
5488 throw new Error(text);
5489 }
5490}
5491
5492/**
5493 * Adjusts extension parameters for NovelAI. Applies Anlas guard if needed.
5494 * @returns {{steps: number, width: number, height: number, sm: boolean, sm_dyn: boolean}} - A tuple of parameters for NovelAI API.
5495 */
5496function getNovelParams() {
5497 let steps = Math.min(extension_settings.sd.steps, 50);
5498 let width = extension_settings.sd.width;
5499 let height = extension_settings.sd.height;
5500 let sm = extension_settings.sd.novel_sm;
5501 let sm_dyn = extension_settings.sd.novel_sm_dyn;
5502
5503 // If a source was never changed after the scheduler setting was added, we need to set it to 'karras' for compatibility.
5504 const schedulers = loadNovelSchedulers();
5505 if (!schedulers.includes(extension_settings.sd.scheduler)) {
5506 extension_settings.sd.scheduler = 'karras';
5507 }
5508
5509 if (extension_settings.sd.sampler === 'ddim' ||
5510 ['nai-diffusion-4-curated-preview', 'nai-diffusion-4-full'].includes(extension_settings.sd.model)) {
5511 sm = false;
5512 sm_dyn = false;
5513 }
5514
5515 // Don't apply Anlas guard if it's disabled.
5516 if (!extension_settings.sd.novel_anlas_guard) {
5517 return { steps, width, height, sm, sm_dyn };
5518 }
5519
5520 const MAX_STEPS = 28;
5521 const MAX_PIXELS = 1024 * 1024;
5522
5523 if (width * height > MAX_PIXELS) {
5524 const ratio = Math.sqrt(MAX_PIXELS / (width * height));
5525
5526 // Calculate new width and height while maintaining aspect ratio.
5527 let newWidth = Math.round(width * ratio);
5528 let newHeight = Math.round(height * ratio);
5529
5530 // Ensure new dimensions are multiples of 64. If not, reduce accordingly.
5531 if (newWidth % 64 !== 0) {
5532 newWidth = newWidth - newWidth % 64;
5533 }
5534
5535 if (newHeight % 64 !== 0) {
5536 newHeight = newHeight - newHeight % 64;
5537 }
5538
5539 // If total pixel count after rounding still exceeds MAX_PIXELS, decrease dimension size by 64 accordingly.
5540 while (newWidth * newHeight > MAX_PIXELS) {
5541 if (newWidth > newHeight) {
5542 newWidth -= 64;
5543 } else {
5544 newHeight -= 64;
5545 }
5546 }
5547
5548 console.log(`Anlas Guard: Image size (${width}x${height}) > ${MAX_PIXELS}, reducing size to ${newWidth}x${newHeight}`);
5549 width = newWidth;
5550 height = newHeight;
5551 }
5552
5553 if (steps > MAX_STEPS) {
5554 console.log(`Anlas Guard: Steps (${steps}) > ${MAX_STEPS}, reducing steps to ${MAX_STEPS}`);
5555 steps = MAX_STEPS;
5556 }
5557
5558 return { steps, width, height, sm, sm_dyn };
5559}
5560
5561/**
5562 * Generates an image in OpenAI API using the provided prompt and configuration settings.
5563 * @param {string} prompt - The main instruction used to guide the image generation.
5564 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5565 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5566 */
5567async function generateOpenAiImage(prompt, signal) {
5568 const dalle2PromptLimit = 1000;
5569 const dalle3PromptLimit = 4000;
5570 const gptImgPromptLimit = 32000;
5571
5572 const isDalle2 = /dall-e-2/.test(extension_settings.sd.model);
5573 const isDalle3 = /dall-e-3/.test(extension_settings.sd.model);
5574 const isGptImg = /gpt-image-(1|2|latest)/.test(extension_settings.sd.model);
5575 const isSora2 = /sora-2/.test(extension_settings.sd.model);
5576
5577 if (isDalle2 && prompt.length > dalle2PromptLimit) {
5578 prompt = prompt.substring(0, dalle2PromptLimit);
5579 }
5580
5581 if (isDalle3 && prompt.length > dalle3PromptLimit) {
5582 prompt = prompt.substring(0, dalle3PromptLimit);
5583 }
5584
5585 if (isGptImg && prompt.length > gptImgPromptLimit) {
5586 prompt = prompt.substring(0, gptImgPromptLimit);
5587 }
5588
5589 let width = 1024;
5590 let height = 1024;
5591 let aspectRatio = extension_settings.sd.width / extension_settings.sd.height;
5592
5593 if (isDalle3 && aspectRatio < 1) {
5594 height = 1792;
5595 }
5596
5597 if (isDalle3 && aspectRatio > 1) {
5598 width = 1792;
5599 }
5600
5601 if (isGptImg && aspectRatio < 1) {
5602 height = 1536;
5603 }
5604
5605 if (isGptImg && aspectRatio > 1) {
5606 width = 1536;
5607 }
5608
5609 if (isDalle2 && (extension_settings.sd.width <= 512 && extension_settings.sd.height <= 512)) {
5610 width = 512;
5611 height = 512;
5612 }
5613
5614 if (isSora2) {
5615 width = aspectRatio >= 1 ? 1280 : 720;
5616 height = aspectRatio >= 1 ? 720 : 1280;
5617
5618 const videoResult = await fetch('/api/openai/generate-video', {
5619 method: 'POST',
5620 headers: getRequestHeaders(),
5621 signal: signal,
5622 body: JSON.stringify({
5623 prompt: prompt,
5624 model: extension_settings.sd.model,
5625 size: `${width}x${height}`,
5626 seconds: extension_settings.sd.openai_duration,
5627 }),
5628 });
5629
5630 if (!videoResult.ok) {
5631 throw new Error(await videoResult.text());
5632 }
5633
5634 const { format, data } = await videoResult.json();
5635 return { format, data };
5636 }
5637
5638 const result = await fetch('/api/openai/generate-image', {
5639 method: 'POST',
5640 headers: getRequestHeaders(),
5641 signal: signal,
5642 body: JSON.stringify({
5643 prompt: prompt,
5644 model: extension_settings.sd.model,
5645 size: `${width}x${height}`,
5646 n: 1,
5647 quality: isDalle3 ? extension_settings.sd.openai_quality : (isGptImg ? extension_settings.sd.openai_quality_gpt : undefined),
5648 style: isDalle3 ? extension_settings.sd.openai_style : undefined,
5649 response_format: isDalle2 || isDalle3 ? 'b64_json' : undefined,
5650 moderation: isGptImg ? 'low' : undefined,
5651 }),
5652 });
5653
5654 if (result.ok) {
5655 const data = await result.json();
5656 return { format: 'png', data: data?.data[0]?.b64_json };
5657 } else {
5658 const text = await result.text();
5659 throw new Error(text);
5660 }
5661}
5662
5663/**
5664 * Universal image generation via AIMLAPI:
5665 * - Builds the right request body for any model (OpenAI vs SD/Flux/Recraft).
5666 * - Extracts the URL or base64 response.
5667 * - If it’s a URL, fetches the image and converts to base64.
5668 * - Returns { format: 'png', data: '<base64 string>' }, ready for saveBase64AsFile().
5669 */
5670async function generateAimlapiImage(prompt, signal) {
5671 const model = extension_settings.sd.model.toLowerCase();
5672 const isSdLike =
5673 model.startsWith('flux/') ||
5674 model.startsWith('stable') ||
5675 model === 'recraft-v3' ||
5676 model === 'triposr';
5677
5678 const body = { prompt, model };
5679 if (isSdLike) {
5680 body.steps = clamp(extension_settings.sd.steps, 1, 50);
5681 body.guidance = clamp(extension_settings.sd.scale, 1.5, 5);
5682 body.width = clamp(extension_settings.sd.width, 256, 1440);
5683 body.height = clamp(extension_settings.sd.height, 256, 1440);
5684 if (extension_settings.sd.seed >= 0) body.seed = extension_settings.sd.seed;
5685 } else {
5686 body.n = 1;
5687 body.size = `${extension_settings.sd.width}x${extension_settings.sd.height}`;
5688 body.quality = extension_settings.sd.openai_quality;
5689 body.style = extension_settings.sd.openai_style;
5690 }
5691
5692 const res = await fetch('/api/sd/aimlapi/generate-image', {
5693 method: 'POST',
5694 headers: getRequestHeaders(),
5695 signal,
5696 body: JSON.stringify(body),
5697 });
5698 if (!res.ok) throw new Error(await res.text());
5699
5700 const { format, data } = await res.json();
5701 return { format, data };
5702}
5703
5704/**
5705 * Generates an image in local ComfyUI or serverless runpod using the provided prompt and configuration settings.
5706 *
5707 * @param {string} prompt - The main instruction used to guide the image generation.
5708 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5709 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5710 * @param {string} basePath - ST server endpoint for the service. '/api/sd/comfy' for local, '/api/sd/comfyrunpod' for serverless.
5711 * @param {string[]} placeholders - Array of substitutions to apply to the workflow.
5712 * @param {string} url - The url of the service to call. Passed to ST server.
5713 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5714 */
5715async function generateComfyImageCommon(prompt, negativePrompt, signal, basePath, placeholders, url) {
5716 const workflowResponse = await fetch('/api/sd/comfy/workflow', {
5717 method: 'POST',
5718 headers: getRequestHeaders(),
5719 body: JSON.stringify({
5720 file_name: extension_settings.sd.comfy_workflow,
5721 }),
5722 });
5723 if (!workflowResponse.ok) {
5724 const text = await workflowResponse.text();
5725 toastr.error(`Failed to load workflow.\n\n${text}`);
5726 }
5727 let workflow = (await workflowResponse.json()).replaceAll('"%prompt%"', JSON.stringify(prompt));
5728 workflow = workflow.replaceAll('"%negative_prompt%"', JSON.stringify(negativePrompt));
5729
5730 const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
5731 workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
5732
5733 const denoising_strength = extension_settings.sd.denoising_strength === undefined ? 1.0 : extension_settings.sd.denoising_strength;
5734 workflow = workflow.replaceAll('"%denoise%"', JSON.stringify(denoising_strength));
5735
5736 const clip_skip = isNaN(extension_settings.sd.clip_skip) ? -1 : -extension_settings.sd.clip_skip;
5737 workflow = workflow.replaceAll('"%clip_skip%"', JSON.stringify(clip_skip));
5738
5739 placeholders.forEach(ph => {
5740 workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
5741 });
5742 (extension_settings.sd.comfy_placeholders ?? []).forEach(ph => {
5743 workflow = workflow.replaceAll(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
5744 });
5745 // Log the workflow before image payloads are substituted in: keeps private
5746 // image data (avatars, reference images) out of the console and avoids
5747 // scanning multi-megabyte strings (a redaction regex here previously blew
5748 // the stack on large reference images).
5749 console.log(`{
5750 "prompt": ${workflow}
5751 }`);
5752 if (/%user_avatar%/gi.test(workflow)) {
5753 const response = await fetch(getUserAvatarUrl());
5754 if (response.ok) {
5755 const avatarBlob = await response.blob();
5756 const avatarBase64DataUrl = await getBase64Async(avatarBlob);
5757 const avatarBase64 = avatarBase64DataUrl.split(',')[1];
5758 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
5759 } else {
5760 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
5761 }
5762 }
5763 if (/%char_avatar%/gi.test(workflow)) {
5764 const response = await fetch(getCharacterAvatarUrl());
5765 if (response.ok) {
5766 const avatarBlob = await response.blob();
5767 const avatarBase64DataUrl = await getBase64Async(avatarBlob);
5768 const avatarBase64 = avatarBase64DataUrl.split(',')[1];
5769 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
5770 } else {
5771 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
5772 }
5773 }
5774 if (REFERENCE_IMAGE_PLACEHOLDER.test(workflow)) {
5775 const refImage = await resolveReferenceImageForGeneration(prompt);
5776 const refBase64 = (refImage && await fetchReferenceImageBase64(refImage)) || PNG_PIXEL;
5777 if (refBase64 !== PNG_PIXEL) {
5778 console.log(`SD: workflow reference image: "${refImage.tag}" (${refImage.path})`);
5779 toastr.info(`Reference image: ${refImage.description || refImage.tag || refImage.path}`, 'Image Generation');
5780 } else {
5781 console.warn('SD: workflow uses %reference_image% but no library image was available; sending a transparent pixel');
5782 }
5783 workflow = workflow.replaceAll('"%reference_image%"', JSON.stringify(refBase64));
5784 workflow = workflow.replaceAll('"%reference-image%"', JSON.stringify(refBase64));
5785 }
5786 const promptResult = await fetch(`${basePath}/generate`, {
5787 method: 'POST',
5788 headers: getRequestHeaders(),
5789 signal: signal,
5790 body: JSON.stringify({
5791 url,
5792 prompt: `{
5793 "prompt": ${workflow}
5794 }`,
5795 }),
5796 });
5797 if (!promptResult.ok) {
5798 const text = await promptResult.text();
5799 throw new Error(text);
5800 }
5801 const { format, data } = await promptResult.json();
5802 return { format, data };
5803}
5804
5805/**
5806 * Generates an image in ComfyUI using the provided prompt and configuration settings.
5807 *
5808 * @param {string} prompt - The main instruction used to guide the image generation.
5809 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5810 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5811 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5812 */
5813async function generateComfyImage(prompt, negativePrompt, signal) {
5814 if (isRunpodProxyUrl(extension_settings.sd.comfy_url) && !(await isRunpodReady(SOURCE_PROBE_TIMEOUT_MS))) {
5815 throw new Error('RunPod on-demand pod is not ready. Start it with the Warm up control.');
5816 }
5817 const placeholders = [
5818 'model',
5819 'vae',
5820 'lora',
5821 'lora_strength',
5822 'sampler',
5823 'scheduler',
5824 'steps',
5825 'scale',
5826 'width',
5827 'height',
5828 ];
5829 return generateComfyImageCommon(prompt, negativePrompt, signal, '/api/sd/comfy', placeholders, extension_settings.sd.comfy_url);
5830}
5831
5832/**
5833 * Generates an image using ComfyUI through serverless runpod endpoint using the provided prompt and configuration settings.
5834 *
5835 * @param {string} prompt - The main instruction used to guide the image generation.
5836 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5837 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5838 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5839 */
5840async function generateComfyRunPodImage(prompt, negativePrompt, signal) {
5841 const placeholders = [
5842 'steps',
5843 'scale',
5844 'width',
5845 'height',
5846 ];
5847
5848 return generateComfyImageCommon(prompt, negativePrompt, signal, '/api/sd/comfyrunpod', placeholders, extension_settings.sd.comfy_runpod_url);
5849}
5850
5851/**
5852 * Generates an image in Hugging Face Inference API using the provided prompt and configuration settings (model selected).
5853 * @param {string} prompt - The main instruction used to guide the image generation.
5854 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5855 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5856 */
5857async function generateHuggingFaceImage(prompt, signal) {
5858 const result = await fetch('/api/sd/huggingface/generate', {
5859 method: 'POST',
5860 headers: getRequestHeaders(),
5861 signal: signal,
5862 body: JSON.stringify({
5863 model: extension_settings.sd.huggingface_model_id,
5864 prompt: prompt,
5865 }),
5866 });
5867
5868 if (result.ok) {
5869 const data = await result.json();
5870 return { format: 'jpg', data: data.image };
5871 } else {
5872 const text = await result.text();
5873 throw new Error(text);
5874 }
5875}
5876
5877/**
5878 * Generates an image using the Chutes API.
5879 * @param {string} prompt - The main instruction used to guide the image generation.
5880 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5881 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5882 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5883 */
5884async function generateChutesImage(prompt, negativePrompt, signal) {
5885 const result = await fetch('/api/sd/chutes/generate', {
5886 method: 'POST',
5887 headers: getRequestHeaders(),
5888 signal: signal,
5889 body: JSON.stringify({
5890 model: extension_settings.sd.model,
5891 prompt: prompt,
5892 negative_prompt: negativePrompt,
5893 width: extension_settings.sd.width,
5894 height: extension_settings.sd.height,
5895 steps: extension_settings.sd.steps,
5896 guidance_scale: extension_settings.sd.scale,
5897 }),
5898 });
5899
5900 if (result.ok) {
5901 const data = await result.json();
5902 return { format: 'jpg', data: data.image };
5903 } else {
5904 const text = await result.text();
5905 throw new Error(text);
5906 }
5907}
5908
5909/**
5910 * Generates an image using the Electron Hub API.
5911 * @param {string} prompt - The main instruction used to guide the image generation.
5912 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5913 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5914 */
5915async function generateElectronHubImage(prompt, signal) {
5916 const size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height);
5917
5918 const result = await fetch('/api/sd/electronhub/generate', {
5919 method: 'POST',
5920 headers: getRequestHeaders(),
5921 signal: signal,
5922 body: JSON.stringify({
5923 model: extension_settings.sd.model,
5924 prompt: prompt,
5925 size: size,
5926 quality: String(extension_settings.sd.electronhub_quality || '').trim() || undefined,
5927 }),
5928 });
5929
5930 if (result.ok) {
5931 const data = await result.json();
5932 return { format: 'jpg', data: data.image };
5933 } else {
5934 const text = await result.text();
5935 throw new Error(text);
5936 }
5937}
5938
5939/**
5940 * Generates an image using the NanoGPT API.
5941 * @param {string} prompt - The main instruction used to guide the image generation.
5942 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5943 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5944 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5945 */
5946async function generateNanoGPTImage(prompt, negativePrompt, signal) {
5947 const result = await fetch('/api/sd/nanogpt/generate', {
5948 method: 'POST',
5949 headers: getRequestHeaders(),
5950 signal: signal,
5951 body: JSON.stringify({
5952 model: extension_settings.sd.model,
5953 prompt: prompt,
5954 negative_prompt: negativePrompt,
5955 num_steps: parseInt(extension_settings.sd.steps),
5956 scale: parseFloat(extension_settings.sd.scale),
5957 width: parseInt(extension_settings.sd.width),
5958 height: parseInt(extension_settings.sd.height),
5959 resolution: `${extension_settings.sd.width}x${extension_settings.sd.height}`,
5960 showExplicitContent: true,
5961 nImages: 1,
5962 }),
5963 });
5964
5965 if (result.ok) {
5966 const data = await result.json();
5967 return { format: 'jpg', data: data.image };
5968 } else {
5969 const text = await result.text();
5970 throw new Error(text);
5971 }
5972}
5973
5974/**
5975 * Generates an image using the BFL API.
5976 * @param {string} prompt - The main instruction used to guide the image generation.
5977 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5978 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5979 */
5980async function generateBflImage(prompt, signal) {
5981 const result = await fetch('/api/sd/bfl/generate', {
5982 method: 'POST',
5983 headers: getRequestHeaders(),
5984 signal: signal,
5985 body: JSON.stringify({
5986 prompt: prompt,
5987 model: extension_settings.sd.model,
5988 steps: clamp(extension_settings.sd.steps, 1, 50),
5989 guidance: clamp(extension_settings.sd.scale, 1.5, 5),
5990 width: clamp(extension_settings.sd.width, 256, 1440),
5991 height: clamp(extension_settings.sd.height, 256, 1440),
5992 prompt_upsampling: !!extension_settings.sd.bfl_upsampling,
5993 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5994 }),
5995 });
5996
5997 if (result.ok) {
5998 const data = await result.json();
5999 return { format: 'jpg', data: data.image };
6000 } else {
6001 const text = await result.text();
6002 throw new Error(text);
6003 }
6004}
6005
6006/**
6007 * Generates an image using the xAI API.
6008 * @param {string} prompt The main instruction used to guide the image generation.
6009 * @param {string} _negativePrompt Negative prompt is not used in this API
6010 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
6011 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
6012 */
6013async function generateXAIImage(prompt, _negativePrompt, signal) {
6014 let aspectRatio;
6015 let resolution;
6016
6017 if (/grok-imagine/.test(extension_settings.sd.model)) {
6018 const resolutionThreshold = 1296 * 864;
6019 const use2kResolution = (extension_settings.sd.width * extension_settings.sd.height) > resolutionThreshold;
6020 aspectRatio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'xai');
6021 resolution = use2kResolution ? '2k' : '1k';
6022 }
6023
6024 const result = await fetch('/api/sd/xai/generate', {
6025 method: 'POST',
6026 headers: getRequestHeaders(),
6027 signal: signal,
6028 body: JSON.stringify({
6029 prompt: prompt,
6030 model: extension_settings.sd.model,
6031 aspect_ratio: aspectRatio,
6032 resolution: resolution,
6033 }),
6034 });
6035
6036 if (result.ok) {
6037 const data = await result.json();
6038 return { format: data.format, data: data.image };
6039 } else {
6040 const text = await result.text();
6041 throw new Error(text);
6042 }
6043}
6044
6045/**
6046 * Generates an image using the FAL.AI API.
6047 * @param {string} prompt - The main instruction used to guide the image generation.
6048 * @param {string} negativePrompt - The negative prompt used to guide the image generation.
6049 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
6050 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
6051 */
6052async function generateFalaiImage(prompt, negativePrompt, signal) {
6053 const result = await fetch('/api/sd/falai/generate', {
6054 method: 'POST',
6055 headers: getRequestHeaders(),
6056 signal: signal,
6057 body: JSON.stringify({
6058 prompt: prompt,
6059 negative_prompt: negativePrompt,
6060 model: extension_settings.sd.model,
6061 steps: clamp(extension_settings.sd.steps, 1, 50),
6062 guidance: clamp(extension_settings.sd.scale, 1.5, 5),
6063 width: clamp(extension_settings.sd.width, 256, 1440),
6064 height: clamp(extension_settings.sd.height, 256, 1440),
6065 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
6066 }),
6067 });
6068
6069 if (result.ok) {
6070 const data = await result.json();
6071 return { format: 'jpg', data: data.image };
6072 } else {
6073 const text = await result.text();
6074 throw new Error(text);
6075 }
6076}
6077
6078/**
6079 * Generates an image using the Google Vertex AI API.
6080 * @param {string} prompt The main instruction used to guide the image generation.
6081 * @param {string} negativePrompt The instruction used to restrict the image generation.
6082 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
6083 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
6084 */
6085async function generateGoogleImage(prompt, negativePrompt, signal) {
6086 const isVeo = /^veo-/.test(extension_settings.sd.model);
6087
6088 if (isVeo) {
6089 const aspectRatio = extension_settings.sd.width / extension_settings.sd.height;
6090 const maxPromptLength = 3000; // 1024 tokens approx.
6091 const videoResult = await fetch('/api/google/generate-video', {
6092 method: 'POST',
6093 headers: getRequestHeaders(),
6094 signal: signal,
6095 body: JSON.stringify({
6096 prompt: prompt.slice(0, maxPromptLength),
6097 aspect_ratio: aspectRatio >= 1 ? '16:9' : '9:16',
6098 seconds: extension_settings.sd.google_duration,
6099 negative_prompt: negativePrompt,
6100 model: extension_settings.sd.model,
6101 api: extension_settings.sd.google_api || 'makersuite',
6102 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
6103 vertexai_auth_mode: oai_settings.vertexai_auth_mode,
6104 vertexai_region: oai_settings.vertexai_region,
6105 vertexai_express_project_id: oai_settings.vertexai_express_project_id,
6106 }),
6107 });
6108
6109 if (!videoResult.ok) {
6110 const text = await videoResult.text();
6111 throw new Error(text);
6112 }
6113
6114 const data = await videoResult.json();
6115 return { format: 'mp4', data: data.video };
6116 }
6117
6118 const result = await fetch('/api/google/generate-image', {
6119 method: 'POST',
6120 headers: getRequestHeaders(),
6121 signal: signal,
6122 body: JSON.stringify({
6123 prompt: prompt,
6124 aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'google'),
6125 negative_prompt: negativePrompt,
6126 model: extension_settings.sd.model,
6127 enhance: extension_settings.sd.google_enhance,
6128 api: extension_settings.sd.google_api || 'makersuite',
6129 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
6130 vertexai_auth_mode: oai_settings.vertexai_auth_mode,
6131 vertexai_region: oai_settings.vertexai_region,
6132 vertexai_express_project_id: oai_settings.vertexai_express_project_id,
6133 }),
6134 });
6135
6136 if (result.ok) {
6137 const data = await result.json();
6138 return { format: 'jpg', data: data.image };
6139 } else {
6140 const text = await result.text();
6141 throw new Error(text);
6142 }
6143}
6144
6145/**
6146 * Generates an image using the Z.AI API.
6147 * @param {string} prompt The main instruction used to guide the image generation.
6148 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
6149 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
6150 */
6151async function generateZaiImage(prompt, signal) {
6152 // Video generation models (CogVideoX, Viduq1)
6153 if (/(cogvideox|vidu)/.test(extension_settings.sd.model)) {
6154 const videoParams = {};
6155 if (/cogvideox/.test(extension_settings.sd.model)) {
6156 const cogVideoSizes = ['1280x720', '720x1280', '1024x1024', '1080x1920', '2048x1080', '3840x2160'];
6157 videoParams.quality = extension_settings.sd.openai_quality === 'hd' ? 'quality' : 'speed';
6158 videoParams.size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height, cogVideoSizes);
6159 }
6160 if (/vidu/.test(extension_settings.sd.model)) {
6161 videoParams.aspect_ratio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'zai');
6162 }
6163
6164 const videoResult = await fetch('/api/sd/zai/generate-video', {
6165 method: 'POST',
6166 headers: getRequestHeaders(),
6167 signal: signal,
6168 body: JSON.stringify({
6169 prompt: prompt,
6170 model: extension_settings.sd.model,
6171 ...videoParams,
6172 }),
6173 });
6174
6175 if (videoResult.ok) {
6176 const data = await videoResult.json();
6177 return { format: data.format, data: data.video };
6178 }
6179
6180 const text = await videoResult.text();
6181 throw new Error(text);
6182 } else {
6183 // Image generation models (GLM-Image, CogView)
6184 // GLM-Image requires multiples of 32, CogView requires multiples of 16
6185 const isGlmImage = /glm-image/.test(extension_settings.sd.model);
6186 const multiple = isGlmImage ? 32 : 16;
6187
6188 // Round width and height to nearest multiple and clamp to 512-2048 range
6189 let width = clamp(Math.round(extension_settings.sd.width / multiple) * multiple, 512, 2048);
6190 let height = clamp(Math.round(extension_settings.sd.height / multiple) * multiple, 512, 2048);
6191
6192 // CogView has a 2^21px pixel count limit, GLM-Image does not
6193 if (!isGlmImage) {
6194 while ((width * height) > Math.pow(2, 21)) {
6195 if (width >= height) {
6196 width -= multiple;
6197 } else {
6198 height -= multiple;
6199 }
6200 }
6201 }
6202
6203 const result = await fetch('/api/sd/zai/generate', {
6204 method: 'POST',
6205 headers: getRequestHeaders(),
6206 signal: signal,
6207 body: JSON.stringify({
6208 prompt: prompt,
6209 model: extension_settings.sd.model,
6210 quality: extension_settings.sd.openai_quality,
6211 size: `${width}x${height}`,
6212 }),
6213 });
6214
6215 if (result.ok) {
6216 const data = await result.json();
6217 return { format: data.format, data: data.image };
6218 }
6219
6220 const text = await result.text();
6221 throw new Error(text);
6222 }
6223}
6224
6225/**
6226 * Generates an image using the OpenRouter API.
6227 * @param {string} prompt The main instruction used to guide the image generation.
6228 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
6229 * @returns {Promise<{format: string, data: string}>}
6230 */
6231async function generateOpenRouterImage(prompt, signal) {
6232 const result = await fetch('/api/openrouter/image/generate', {
6233 method: 'POST',
6234 headers: getRequestHeaders(),
6235 signal: signal,
6236 body: JSON.stringify({
6237 model: extension_settings.sd.model,
6238 prompt: prompt,
6239 aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'stability'),
6240 }),
6241 });
6242
6243 if (result.ok) {
6244 const data = await result.json();
6245 return { format: 'jpg', data: data.image };
6246 }
6247
6248 const text = await result.text();
6249 throw new Error(text);
6250}
6251
6252async function generateWorkersAIImage(prompt, negativePrompt, signal) {
6253 const result = await fetch('/api/sd/workersai/generate', {
6254 method: 'POST',
6255 headers: getRequestHeaders(),
6256 signal: signal,
6257 body: JSON.stringify({
6258 prompt: prompt,
6259 negative_prompt: negativePrompt,
6260 model: extension_settings.sd.model,
6261 width: extension_settings.sd.width,
6262 height: extension_settings.sd.height,
6263 steps: extension_settings.sd.steps,
6264 scale: extension_settings.sd.scale,
6265 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
6266 account_id: oai_settings.workers_ai_account_id,
6267 }),
6268 });
6269
6270 if (result.ok) {
6271 const data = await result.json();
6272 return { format: data?.format, data: data?.image };
6273 } else {
6274 const text = await result.text();
6275 throw new Error(text);
6276 }
6277}
6278
6279async function onComfyOpenWorkflowEditorClick() {
6280 let workflow = await (await fetch('/api/sd/comfy/workflow', {
6281 method: 'POST',
6282 headers: getRequestHeaders(),
6283 body: JSON.stringify({
6284 file_name: extension_settings.sd.comfy_workflow,
6285 }),
6286 })).json();
6287 const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html'));
6288 const saveValue = (/** @type {Popup} */ _popup) => {
6289 workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
6290 return true;
6291 };
6292 const popup = new Popup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: 'Save', cancelButton: 'Cancel', wide: true, large: true, onClosing: saveValue });
6293 const popupResult = popup.show();
6294 const checkPlaceholders = () => {
6295 workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
6296 $('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function () {
6297 const key = this.getAttribute('data-placeholder');
6298 const found = workflow.search(`"%${key}%"`) !== -1;
6299 this.classList[found ? 'remove' : 'add']('sd_comfy_workflow_editor_not_found');
6300 });
6301 };
6302 $('#sd_comfy_workflow_editor_name').text(extension_settings.sd.comfy_workflow);
6303 $('#sd_comfy_workflow_editor_workflow').val(workflow);
6304 const addPlaceholderDom = (placeholder) => {
6305 const el = $(`
6306 <li class="sd_comfy_workflow_editor_not_found" data-placeholder="${placeholder.find}">
6307 <span class="sd_comfy_workflow_editor_custom_remove" title="Remove custom placeholder">⊘</span>
6308 <span class="sd_comfy_workflow_editor_custom_final">"%${placeholder.find}%"</span><br>
6309 <input placeholder="find" title="find" type="text" class="text_pole sd_comfy_workflow_editor_custom_find" value=""><br>
6310 <input placeholder="replace" title="replace" type="text" class="text_pole sd_comfy_workflow_editor_custom_replace">
6311 </li>
6312 `);
6313 $('#sd_comfy_workflow_editor_placeholder_list_custom').append(el);
6314 el.find('.sd_comfy_workflow_editor_custom_find').val(placeholder.find);
6315 el.find('.sd_comfy_workflow_editor_custom_find').on('input', function () {
6316 if (!(this instanceof HTMLInputElement)) {
6317 return;
6318 }
6319 placeholder.find = this.value;
6320 el.find('.sd_comfy_workflow_editor_custom_final').text(`"%${this.value}%"`);
6321 el.attr('data-placeholder', `${this.value}`);
6322 checkPlaceholders();
6323 saveSettingsDebounced();
6324 });
6325 el.find('.sd_comfy_workflow_editor_custom_replace').val(placeholder.replace);
6326 el.find('.sd_comfy_workflow_editor_custom_replace').on('input', function () {
6327 if (!(this instanceof HTMLInputElement)) {
6328 return;
6329 }
6330 placeholder.replace = this.value;
6331 saveSettingsDebounced();
6332 });
6333 el.find('.sd_comfy_workflow_editor_custom_remove').on('click', () => {
6334 el.remove();
6335 extension_settings.sd.comfy_placeholders.splice(extension_settings.sd.comfy_placeholders.indexOf(placeholder));
6336 saveSettingsDebounced();
6337 });
6338 };
6339 $('#sd_comfy_workflow_editor_placeholder_add').on('click', () => {
6340 if (!extension_settings.sd.comfy_placeholders) {
6341 extension_settings.sd.comfy_placeholders = [];
6342 }
6343 const placeholder = {
6344 find: '',
6345 replace: '',
6346 };
6347 extension_settings.sd.comfy_placeholders.push(placeholder);
6348 addPlaceholderDom(placeholder);
6349 saveSettingsDebounced();
6350 });
6351 (extension_settings.sd.comfy_placeholders ?? []).forEach(placeholder => {
6352 addPlaceholderDom(placeholder);
6353 });
6354 checkPlaceholders();
6355 $('#sd_comfy_workflow_editor_workflow').on('input', checkPlaceholders);
6356 if (await popupResult) {
6357 const response = await fetch('/api/sd/comfy/save-workflow', {
6358 method: 'POST',
6359 headers: getRequestHeaders(),
6360 body: JSON.stringify({
6361 file_name: extension_settings.sd.comfy_workflow,
6362 workflow: workflow,
6363 }),
6364 });
6365 if (!response.ok) {
6366 const text = await response.text();
6367 toastr.error(`Failed to save workflow.\n\n${text}`);
6368 }
6369 }
6370}
6371
6372async function onComfyNewWorkflowClick() {
6373 let name = await callGenericPopup('Workflow name:', POPUP_TYPE.INPUT);
6374 if (!name) {
6375 return;
6376 }
6377 if (!String(name).toLowerCase().endsWith('.json')) {
6378 name += '.json';
6379 }
6380 extension_settings.sd.comfy_workflow = name;
6381 const response = await fetch('/api/sd/comfy/save-workflow', {
6382 method: 'POST',
6383 headers: getRequestHeaders(),
6384 body: JSON.stringify({
6385 file_name: extension_settings.sd.comfy_workflow,
6386 workflow: '',
6387 }),
6388 });
6389 if (!response.ok) {
6390 const text = await response.text();
6391 toastr.error(`Failed to save workflow.\n\n${text}`);
6392 }
6393 saveSettingsDebounced();
6394 await loadComfyWorkflows();
6395 await delay(200);
6396 await onComfyOpenWorkflowEditorClick();
6397}
6398
6399async function onComfyDeleteWorkflowClick() {
6400 const confirm = await callGenericPopup(t`Delete the workflow? This action is irreversible.`, POPUP_TYPE.CONFIRM, '', { okButton: t`Delete`, cancelButton: t`Cancel` });
6401 if (!confirm) {
6402 return;
6403 }
6404 const response = await fetch('/api/sd/comfy/delete-workflow', {
6405 method: 'POST',
6406 headers: getRequestHeaders(),
6407 body: JSON.stringify({
6408 file_name: extension_settings.sd.comfy_workflow,
6409 }),
6410 });
6411 if (!response.ok) {
6412 const text = await response.text();
6413 toastr.error(`Failed to save workflow.\n\n${text}`);
6414 }
6415 await loadComfyWorkflows();
6416 onComfyWorkflowChange();
6417}
6418
6419async function onComfyRenameWorkflowClick() {
6420 const oldName = extension_settings.sd.comfy_workflow;
6421
6422 if (!oldName) {
6423 return;
6424 }
6425
6426 let newName = await callGenericPopup(t`Enter new workflow name:`, POPUP_TYPE.INPUT, oldName);
6427
6428 if (!newName) {
6429 return;
6430 }
6431
6432 newName = String(newName).trim();
6433
6434 if (!newName.toLowerCase().endsWith('.json')) {
6435 newName += '.json';
6436 }
6437
6438 if (newName === oldName) {
6439 return;
6440 }
6441
6442 const existingWorkflow = Array
6443 .from(document.querySelectorAll('#sd_comfy_workflow option'))
6444 .find(opt => opt instanceof HTMLOptionElement && opt.value === newName);
6445
6446 if (existingWorkflow) {
6447 toastr.warning(t`A workflow with that name already exists`);
6448 return;
6449 }
6450
6451 const response = await fetch('/api/sd/comfy/rename-workflow', {
6452 method: 'POST',
6453 headers: getRequestHeaders(),
6454 body: JSON.stringify({
6455 old_name: oldName,
6456 new_name: newName,
6457 }),
6458 });
6459
6460 if (!response.ok) {
6461 const text = await response.text();
6462 toastr.error(t`Failed to rename workflow.\n\n${text}`);
6463 return;
6464 }
6465
6466 extension_settings.sd.comfy_workflow = newName;
6467 saveSettingsDebounced();
6468 await loadComfyWorkflows();
6469}
6470
6471/**
6472 * Sends a chat message with the generated image.
6473 * @param {string} prompt Prompt used for the image generation
6474 * @param {string} image Base64 encoded image
6475 * @param {number} generationType Generation type of the image
6476 * @param {string} additionalNegativePrefix Additional negative prompt used for the image generation
6477 * @param {string} initiator The initiator of the image generation
6478 * @param {string} prefixedPrompt Prompt with an attached specific prefix
6479 * @param {string} format Format of the image (e.g., 'png', 'jpg')
6480 */
6481async function sendMessage(prompt, image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, format) {
6482 const context = getContext();
6483 const name = context.groupId ? systemUserName : context.name2;
6484 const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}';
6485 const messageText = substituteParamsExtended(template, { char: name, prompt: prompt, prefixedPrompt: prefixedPrompt });
6486 const mediaType = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE;
6487 /** @type {MediaAttachment} */
6488 const mediaAttachment = {
6489 url: image,
6490 type: mediaType,
6491 title: prompt,
6492 generation_type: generationType,
6493 negative: additionalNegativePrefix,
6494 source: MEDIA_SOURCE.GENERATED,
6495 };
6496 /** @type {ChatMessage} */
6497 const message = {
6498 name: name,
6499 is_user: false,
6500 is_system: !getVisibilityByInitiator(initiator),
6501 send_date: getMessageTimeStamp(),
6502 mes: messageText,
6503 extra: {
6504 media: [mediaAttachment],
6505 media_display: MEDIA_DISPLAY.GALLERY,
6506 media_index: 0,
6507 inline_image: false,
6508 sd_prompt: prompt,
6509 sd_prompt_message: messageText,
6510 },
6511 };
6512 context.chat.push(message);
6513 const messageId = context.chat.length - 1;
6514 await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension');
6515 context.addOneMessage(message);
6516 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, messageId, 'extension');
6517 await context.saveChat();
6518 setTimeout(() => context.scrollOnMediaLoad(), debounce_timeout.short);
6519}
6520
6521/**
6522 * Gets the visibility of the resulting message based on the initiator.
6523 * @param {string} initiator Generation initiator
6524 * @returns {boolean} Is resulting message visible
6525 */
6526function getVisibilityByInitiator(initiator) {
6527 switch (initiator) {
6528 case initiators.interactive:
6529 return !!extension_settings.sd.interactive_visible;
6530 case initiators.wand:
6531 return !!extension_settings.sd.wand_visible;
6532 case initiators.command:
6533 return !!extension_settings.sd.command_visible;
6534 case initiators.tool:
6535 return !!extension_settings.sd.tool_visible;
6536 default:
6537 return false;
6538 }
6539}
6540
6541async function addSDGenButtons() {
6542 const buttonHtml = await renderExtensionTemplateAsync('stable-diffusion', 'button');
6543 const dropdownHtml = await renderExtensionTemplateAsync('stable-diffusion', 'dropdown');
6544
6545 $('#sd_wand_container').append(buttonHtml);
6546 $(document.body).append(dropdownHtml);
6547
6548 const button = $('#sd_gen');
6549 const dropdown = $('#sd_dropdown');
6550 dropdown.hide();
6551
6552 let popper = Popper.createPopper(button.get(0), dropdown.get(0), {
6553 placement: 'top',
6554 });
6555
6556 $(document).on('click', '.sd_message_gen', (e) => sdMessageButton($(e.currentTarget), { animate: false }));
6557 $(document).on('click', '.mes_edit', rememberImagePromptBeforeEdit);
6558
6559 $(document).on('click touchend', function (e) {
6560 const target = $(e.target);
6561 if (target.is(dropdown) || target.closest(dropdown).length) return;
6562 if ((target.is(button) || target.closest(button).length) && !dropdown.is(':visible')) {
6563 e.preventDefault();
6564
6565 dropdown.fadeIn(animation_duration);
6566 popper.update();
6567 } else {
6568 dropdown.fadeOut(animation_duration);
6569 }
6570 });
6571
6572 renderCustomDropdownEntries();
6573
6574 // Use event delegation so dynamically-added custom entries also respond to clicks.
6575 $('#sd_dropdown').on('click', 'li[id]', function () {
6576 dropdown.fadeOut(animation_duration);
6577 const id = $(this).attr('id');
6578 const idParamMap = {
6579 'sd_you': 'you',
6580 'sd_face': 'face',
6581 'sd_me': 'me',
6582 'sd_world': 'scene',
6583 'sd_last': 'last',
6584 'sd_raw_last': 'raw_last',
6585 'sd_background': 'background',
6586 };
6587
6588 const param = idParamMap[id];
6589
6590 if (param) {
6591 console.log('doing /sd ' + param);
6592 generatePicture(initiators.wand, {}, param);
6593 return;
6594 }
6595
6596 if (id && id.startsWith('sd_custom_')) {
6597 const entryId = id.slice('sd_custom_'.length);
6598 console.log('doing /sd custom_' + entryId);
6599 generatePicture(initiators.wand, {}, 'custom_' + entryId);
6600 }
6601 });
6602}
6603
6604/**
6605 * Renders the user-defined custom entries into the wand dropdown.
6606 * Removes any previously rendered custom entries first.
6607 */
6608function renderCustomDropdownEntries() {
6609 const list = $('#sd_dropdown ul.list-group');
6610 if (!list.length) {
6611 return;
6612 }
6613
6614 list.find('li.sd_custom_entry').remove();
6615
6616 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
6617 for (const entry of entries) {
6618 const li = $('<li></li>')
6619 .addClass('list-group-item sd_custom_entry')
6620 .attr('id', 'sd_custom_' + entry.id)
6621 .text(entry.title);
6622 list.append(li);
6623 }
6624}
6625
6626function isValidState() {
6627 switch (extension_settings.sd.source) {
6628 case sources.extras:
6629 return modules.includes('sd');
6630 case sources.horde:
6631 return true;
6632 case sources.auto:
6633 return !!extension_settings.sd.auto_url;
6634 case sources.sdcpp:
6635 return !!extension_settings.sd.sdcpp_url;
6636 case sources.drawthings:
6637 return !!extension_settings.sd.drawthings_url;
6638 case sources.vlad:
6639 return !!extension_settings.sd.vlad_url;
6640 case sources.novel:
6641 return secret_state[SECRET_KEYS.NOVEL];
6642 case sources.openai:
public/scripts/extensions/stable-diffusion/manifest.json+0 -0
public/scripts/extensions/stable-diffusion/settings.html+0 -0
public/scripts/extensions/stable-diffusion/style.css+0 -0
public/scripts/openai.js+0 -0
src/endpoints/backends/chat-completions.js+0 -0
src/endpoints/keepalive.js+0 -0
src/endpoints/stable-diffusion.js+0 -0
src/server-startup.js+0 -0
tests/keepalive-scheduler.test.js+0 -0
Diff truncated