Retire custom image after extension migration

513b8d68f5085d16f678c547682d5c038626f8fa

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

32 files changed, +53 -9839Ignore whitespace
.gitlab-ci.yml+0 -56
@@ -1,56 +0,0 @@
1-stages:
2- - build
3- - publish
4-
5-build-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.
33-publish-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
1414/public/scripts/extensions/third-party
1515/colab
1616.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 @@
11# SillyTavern
22
33LLM Frontend for Power Users - with a few additional features
44
55## Resources
66
homelab/README.md+9 -8
@@ -7,22 +7,19 @@ changing one, update both (scp to the NAS path + commit here).
77| File | Deployed at | Purpose |
88|---|---|---|
99| `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`). |
1210| `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. |
1311| `unraid/bootstrap-sillytavern.sh` | `/boot/config/bootstrap-sillytavern.sh` | Recovery bootstrap used when the container or DockerMan template is absent. |
1412| `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
1615## SillyTavern image deployment
1716
18-Every push to `main` builds the complete customized source tree in GitGudLab CI
17+The NAS now runs the official stable release image,
19-and publishes both the commit tag and
18+`ghcr.io/sillytavern/sillytavern:latest`. permissionBRICK features live in
20-`git.nas.home.htl-sky.net/permissionbrick/sillytavern:latest`. The image carries
19+persistent public browser/server extensions instead of a custom image.
21-OCI source, revision, and build-time labels so a running container can be tied
22-back to the exact commit.
2320
2421On Unraid, Community Applications Auto Update has `sillytavern` enabled. A
2522shared registry poller additionally checks the GitGudLabofficial tag every two minutes
2623and invokes `redeploy-sillytavern.sh` when its image ID changes. Do not point
2724the DockerMan template at a different image while leaving that poller enabled:
2825the 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
3734Related: the RunPod worker image (ComfyUI + custom nodes + boot-time model
3835downloader) is built from github.com/permissionBRICK/comfyui-runpod-worker
3936(private repo, public GHCR package; mirrored on this GitGudLab).
37+
38+The RunPod lazy proxy, CLI, tests, container image, and Unraid template moved to
39+the public `permissionBRICK/ST-RunPodProxy` repository. Image Provider
40+Extensions 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-
4-SillyTavern's fallback chain points at this proxy as a normal ComfyUI server.
5-Models come from a CATALOG pushed by the ST UI: entries of
6- {"name": display, "value": model filename, "files": [{"dest": "unet/x.gguf", "url": "..."}]}
7-The pod downloads exactly the selected entry's files at boot (boot-models.py
8-reads MODEL_MANIFEST). Changing the active model with a RUNNING pod downloads
9-the new files IN PLACE through the pod's model-manager (port 8189; LRU-evicts
10-old models if the disk runs short) - the pod is only recreated when it runs an
11-old image without the manager.
12-
13-Control 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-
21-Start semantics: only POST /lazy/warmup may provision or change models. ComfyUI
22-requests return 503 unless a pod is already ready, allowing SillyTavern's image
23-target fallback chain to continue without waking this target. Browser heartbeats
24-grant a short lease during which this proxy sends keepalive probes on a stable
25-backend cadence. The idle timer lives here (NAS side): IDLE_SECONDS after the
26-last activity signal the pod is terminated, frontend or no frontend.
27-"""
28-import json
29-import os
30-import threading
31-import time
32-import urllib.error
33-import urllib.request
34-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
35-
36-RUNPOD_KEY = os.environ['RUNPOD_KEY']
37-LISTEN_PORT = int(os.environ.get('LISTEN_PORT', '8189'))
38-IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
39-KEEPALIVE_SECONDS = max(1, int(os.environ.get('KEEPALIVE_SECONDS', '60')))
40-FRONTEND_LEASE_SECONDS = max(1, int(os.environ.get('FRONTEND_LEASE_SECONDS', '300')))
41-DATACENTERS = [d for d in os.environ.get('DATACENTERS', '').split(',') if d]
42-CLOUD_TYPE = os.environ.get('CLOUD_TYPE', 'SECURE')
43-IMAGE = os.environ.get('IMAGE', 'ghcr.io/permissionbrick/comfyui-runpod-worker:latest')
44-GPU_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).
47-CUDA_VERSIONS = [v for v in os.environ.get('CUDA_VERSIONS', '12.8,12.9,13.0').split(',') if v]
48-POD_NAME = os.environ.get('POD_NAME', 'comfyui-lazy')
49-START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '1500'))
50-HF_TOKEN = os.environ.get('HF_TOKEN', '')
51-COMFY_ARGS = os.environ.get('COMFY_ARGS', '--listen 0.0.0.0 --port 8188 --use-pytorch-cross-attention')
52-CATALOG_FILE = os.environ.get('CATALOG_FILE', '/app/catalog.json')
53-
54-state = {
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-
69-def log(*args):
70- print(time.strftime('%H:%M:%S'), *args, flush=True)
71-
72-
73-def 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-
81-def save_catalog(catalog):
82- with open(CATALOG_FILE, 'w') as f:
83- json.dump(catalog, f, indent=2)
84-
85-
86-def 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-
95-def 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-
102-def values_key(values):
103- return '+'.join(sorted(values)) if values else ''
104-
105-
106-def key_values(key):
107- return set(k for k in (key or '').split('+') if k)
108-
109-
110-def 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-
119-def 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-
126-def 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-
140-def 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-
151-def 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-
162-def 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-
195-def pod_url(pod_id):
196- return f'https://{pod_id}-8188.proxy.runpod.net'
197-
198-
199-def 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-
210-UA = 'Mozilla/5.0 (compatible; runpod-lazy-proxy)'
211-
212-
213-def 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-
225-def 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-
233-def _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-
252-def 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-
367-def 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-
385-def 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-
394-def 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-
413-def 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-
428-def 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-
467-class 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-
572-def 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-
586-if __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.
5-CMD="${1:-status}"
6-URL="${RUNPOD_LAZY_URL:-http://127.0.0.1:8189}"
7-case "$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 ;;
14-esac
15-echo
homelab/tests/test_runpod_lazy_proxy.py+0 -197
@@ -1,197 +0,0 @@
1-import http.client
2-import importlib.util
3-import json
4-import os
5-import pathlib
6-import tempfile
7-import threading
8-import unittest
9-from unittest import mock
10-
11-
12-os.environ.setdefault('RUNPOD_KEY', 'test-key')
13-PROXY_PATH = pathlib.Path(__file__).parents[1] / 'runpod-lazy-proxy.py'
14-SPEC = importlib.util.spec_from_file_location('runpod_lazy_proxy', PROXY_PATH)
15-proxy = importlib.util.module_from_spec(SPEC)
16-SPEC.loader.exec_module(proxy)
17-
18-
19-class 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-
196-if __name__ == '__main__':
197- unittest.main()
homelab/unraid/bootstrap-sillytavern.sh+1 -1
@@ -2,7 +2,7 @@
22# Raw recovery bootstrap. Normal updates go through redeploy-sillytavern.sh.
33set -eu
44
55REF=git.nas.home.htl-skyghcr.netio/permissionbricksillytavern/sillytavern:latest
66
77docker pull "$REF"
88docker rm -f sillytavern >/dev/null 2>&1 || true
homelab/unraid/my-sillytavern.xml+5 -5
@@ -1,17 +1,17 @@
11<?xml version="1.0"?>
22<Container version="2">
33 <Name>sillytavern</Name>
44 <Repository>git.nas.home.htl-skyghcr.netio/permissionbricksillytavern/sillytavern:latest</Repository>
55 <Registry>https://git.nas.home.htl-skygithub.netcom/permissionBRICKSillyTavern/SillyTavern/pkgs/container/sillytavern</Registry>
66 <Network>bridge</Network>
77 <MyIP/>
88 <MyMAC/>
99 <Shell>sh</Shell>
1010 <Privileged>false</Privileged>
1111 <Support>https://github.com/SillyTavern/SillyTavern/issues</Support>
1212 <Project>https://git.nas.home.htl-skygithub.netcom/permissionBRICKSillyTavern/SillyTavern</Project>
1313 <ReadMe>https://git.nas.home.htl-skygithub.netcom/permissionBRICKSillyTavern/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>
1515 <Category>Tools:</Category>
1616 <WebUI>http://nas.home.htl-sky.net:8000/</WebUI>
1717 <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+
9+LOCK=/var/run/registry-autoupdate.lock
10+mkdir "$LOCK" 2>/dev/null || exit 0
11+trap 'rmdir "$LOCK"' EXIT
12+
13+if docker ps --format '{{.Names}}' | grep -q '^gitgud-job-'; then
14+ exit 0
15+fi
16+
17+check() {
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+
32+check gitgudlab git.nas.home.htl-sky.net/permissionbrick/gitgudlab:latest /boot/config/redeploy-gitgudlab.sh
33+check gitgudlab-runner git.nas.home.htl-sky.net/permissionbrick/gitgudlab:runner-latest /boot/config/redeploy-gitgudlab-runner.sh
34+check sillytavern ghcr.io/sillytavern/sillytavern:latest /boot/config/redeploy-sillytavern.sh
public/script.js+3 -35
@@ -4218,7 +4218,6 @@ function removeLastMessage() {
42184218 * @property {string} [quietName] Name to use for the quiet prompt (defaults to "System:")
42194219 * @property {number} [depth] Recursion depth for the generation. Used to prevent infinite loops in tool calls.
42204220 * @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.
42224221 */
42234222
42244223/**
@@ -4229,7 +4228,7 @@ function removeLastMessage() {
42294228 * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt
42304229 * @returns {Promise<any>} Returns a promise that resolves when the text is done generating.
42314230 */
42324231export 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) {
42334232 console.log('Generate entered');
42344233 setGenerationProgress(0);
42354234 generation_started = new Date();
@@ -5241,7 +5240,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
52415240 jailbreakPromptOverride: jailbreak,
52425241 messages: oaiMessages,
52435242 messageExamples: oaiMessageExamples,
5244- squashSystemMessages,
52455243 }, dryRun);
52465244 generate_data = { prompt: prompt };
52475245
@@ -5264,30 +5262,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
52645262 return Promise.resolve();
52655263 }
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-
52915265 /**
52925266 * Saves itemized prompt bits and calls streaming or non-streaming generation API.
52935267 * @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>}
@@ -5357,7 +5331,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
53575331 streamingProcessor.firstMessageText = '';
53585332 }
53595333
5360- await beginProviderRequest();
53615334 streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema });
53625335
53635336 hideSwipeButtons();
@@ -5375,7 +5348,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
53755348
53765349 const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished;
53775350 const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length;
5378- await finishProviderRequest(isStreamFinished);
53795351 if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) {
53805352 const lastMessage = chat[chat.length - 1];
53815353 const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls);
@@ -5415,10 +5387,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
54155387 });
54165388 }
54175389 } 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;
54225391 }
54235392 }
54245393
@@ -5559,13 +5528,12 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
55595528 * @param {Error|object} exception Error or response JSON
55605529 * @throws {Error|object} Re-throws the exception
55615530 */
55625531 async function onError(exception) {
55635532 // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message
55645533 if (typeof exception?.error?.message === 'string') {
55655534 toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 });
55665535 }
55675536
5568- await finishProviderRequest(false);
55695537 unblockGeneration(type);
55705538 console.log(exception);
55715539 streamingProcessor = null;
public/scripts/events.js+0 -4
@@ -23,8 +23,6 @@ export const event_types = {
2323 GENERATION_STARTED: 'generation_started',
2424 GENERATION_STOPPED: 'generation_stopped',
2525 GENERATION_ENDED: 'generation_ended',
26- GENERATION_REQUEST_STARTED: 'generation_request_started',
27- GENERATION_REQUEST_FINISHED: 'generation_request_finished',
2826 SD_PROMPT_PROCESSING: 'sd_prompt_processing',
2927 EXTENSIONS_FIRST_LOAD: 'extensions_first_load',
3028 EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',
@@ -81,8 +79,6 @@ export const event_types = {
8179 ONLINE_STATUS_CHANGED: 'online_status_changed',
8280 IMAGE_SWIPED: 'image_swiped',
8381 CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
84- CONNECTION_PROFILE_TEMPORARY_STARTED: 'connection_profile_temporary_started',
85- CONNECTION_PROFILE_TEMPORARY_ENDED: 'connection_profile_temporary_ended',
8682 CONNECTION_PROFILE_CREATED: 'connection_profile_created',
8783 CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
8884 CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
public/scripts/extensions/keepalive/index.js+0 -664
@@ -1,664 +0,0 @@
1-import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
2-import {
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';
15-import { createGenerationParameters, getChatCompletionModel, oai_settings } from '../../openai.js';
16-import { horde_settings } from '../../horde.js';
17-import { uuidv4 } from '../../utils.js';
18-
19-export { init };
20-
21-const 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.
26-const KEEPALIVE_RESPONSE_LENGTH = 1;
27-const HEARTBEAT_INTERVAL_MS = 20000;
28-const RETRY_INTERVAL_MS = 5000;
29-const SETTINGS_REFRESH_DELAY_MS = 300;
30-const 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.
33-const KEEPALIVE_INJECT_ID = 'keepalive_ping';
34-
35-// Original default message; used to migrate untouched installs to the current default wording.
36-const LEGACY_DEFAULT_MESSAGE = 'Keep the cache warm.';
37-
38-// Idle-tracking key used when no connection profile is active (manual API panel).
39-const NONE_KEY = '__none__';
40-
41-const 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-
49-let heartbeatTimer = null;
50-let jobRegistered = false;
51-let refreshPromise = null;
52-let refreshRequested = false;
53-let refreshTimer = null;
54-let generationRefreshTimer = null;
55-let retryTimer = null;
56-let preparingJob = false;
57-let lifecycleVersion = 0;
58-let temporaryConnectionDepth = 0;
59-let refreshAfterTemporaryConnection = false;
60-const activeMainRequests = new Map();
61-let 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.
65-let 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.
68-const 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.
71-const 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.
75-const keepaliveJobId = uuidv4().replaceAll('-', '');
76-
77-/**
78- * @returns {string} The active connection profile id, or NONE_KEY for a manual connection.
79- */
80-function 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- */
90-function 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-
103-function getTimeoutMs() {
104- return Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000;
105-}
106-
107-function 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-
114-function getLastWarmTime(profileKey = getActiveProfileKey()) {
115- return Math.max(lastActivity[profileKey] || 0, lastKeepalive[profileKey] || 0);
116-}
117-
118-function isWarmPromptExpired(profileKey = getActiveProfileKey()) {
119- const lastWarmTime = getLastWarmTime(profileKey);
120- return lastWarmTime > 0 && (Date.now() - lastWarmTime) >= getTimeoutMs();
121-}
122-
123-function disarmExpiredJob() {
124- armed = false;
125- jobRegistered = false;
126- refreshRequested = false;
127- lifecycleVersion++;
128- clearTimeout(retryTimer);
129- clearTimeout(refreshTimer);
130- clearTimeout(generationRefreshTimer);
131-}
132-
133-async 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-
141-async 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-
152-async 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- */
194-function 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- */
203-function 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- */
216-function 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- */
239-async 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-
299-async 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-
358-function 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-
373-function scheduleJobRefresh() {
374- clearTimeout(refreshTimer);
375- refreshTimer = setTimeout(queueJobRefresh, SETTINGS_REFRESH_DELAY_MS);
376-}
377-
378-function scheduleGenerationJobRefresh() {
379- clearTimeout(generationRefreshTimer);
380- generationRefreshTimer = setTimeout(() => {
381- if (armed && isKeepaliveEnabledForActive()) {
382- queueJobRefresh();
383- }
384- }, SETTINGS_REFRESH_DELAY_MS);
385-}
386-
387-function 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- */
400-function 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- */
436-function 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- */
468-function 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-
514-async 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-
528-async 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-
550-async 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-
564-async 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-
592-function 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-
602-async 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-
613-function onTemporaryConnectionStarted() {
614- temporaryConnectionDepth++;
615-}
616-
617-function onTemporaryConnectionEnded() {
618- temporaryConnectionDepth = Math.max(0, temporaryConnectionDepth - 1);
619- if (temporaryConnectionDepth === 0 && refreshAfterTemporaryConnection) {
620- refreshAfterTemporaryConnection = false;
621- queueJobRefresh();
622- }
623-}
624-
625-async 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 @@
1-import { getStringHash, debounce, waitUntilCondition, extractAllWords, isTrueBoolean } from '../../utils.js';
2-import { getContext, getApiUrl, extension_settings, doExtrasFetch, modules, renderExtensionTemplateAsync } from '../../extensions.js';
3-import {
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';
22-import { is_group_generating, selected_group } from '../../group-chats.js';
23-import { loadMovingUIState, power_user } from '../../power-user.js';
24-import { dragElement } from '../../RossAscends-mods.js';
25-import { getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';
26-import { debounce_timeout } from '../../constants.js';
27-import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
28-import { SlashCommand } from '../../slash-commands/SlashCommand.js';
29-import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
30-import { macros, MacroCategory } from '../../macros/macro-system.js';
31-import { ConnectionManagerRequestService, countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js';
32-import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
33-import { removeReasoningFromString } from '../../reasoning.js';
34-import { MacrosParser } from '/scripts/macros.js';
35-export { MODULE_NAME };
36-
37-const MODULE_NAME = '1_memory';
38-
39-let lastMessageHash = null;
40-let lastMessageId = null;
41-let 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- */
49-async 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-
63-async 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-
78-const 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-
92-const saveChatDebounced = debounce(() => getContext().saveChat(), debounce_timeout.relaxed);
93-
94-const summary_sources = {
95- 'extras': 'extras',
96- 'main': 'main',
97- 'webllm': 'webllm',
98-};
99-
100-const prompt_builders = {
101- DEFAULT: 0,
102- RAW_BLOCKING: 1,
103- RAW_NON_BLOCKING: 2,
104-};
105-
106-const 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.';
107-const defaultTemplate = '[Summary: {{summary}}]';
108-
109-const 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- */
149-function getSummaryPromptRole() {
150- return Number(extension_settings.memory.summaryPromptRole) === extension_prompt_roles.USER
151- ? extension_prompt_roles.USER
152- : extension_prompt_roles.SYSTEM;
153-}
154-
155-let 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- */
162-function 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-
183-function 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-
214-async 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-
252-async 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-
290-function onSummarySourceChange(event) {
291- const value = event.target.value;
292- extension_settings.memory.source = value;
293- switchSourceControls(value);
294- saveSettingsDebounced();
295-}
296-
297-function 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-
304-function onMemoryFrozenInput() {
305- const value = Boolean($(this).prop('checked'));
306- extension_settings.memory.memoryFrozen = value;
307- saveSettingsDebounced();
308-}
309-
310-function onMemorySkipWIANInput() {
311- const value = Boolean($(this).prop('checked'));
312- extension_settings.memory.SkipWIAN = value;
313- saveSettingsDebounced();
314-}
315-
316-function 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-
323-function 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-
330-function onMemoryPromptRestoreClick() {
331- $('#memory_prompt').val(defaultPrompt).trigger('input');
332-}
333-
334-function onMemoryPromptInput() {
335- const value = $(this).val();
336- extension_settings.memory.prompt = value;
337- saveSettingsDebounced();
338-}
339-
340-function onMemoryTemplateInput() {
341- const value = $(this).val();
342- extension_settings.memory.template = value;
343- reinsertMemory();
344- saveSettingsDebounced();
345-}
346-
347-function onMemoryDepthInput() {
348- const value = $(this).val();
349- extension_settings.memory.depth = Number(value);
350- reinsertMemory();
351- saveSettingsDebounced();
352-}
353-
354-function onMemoryRoleInput() {
355- const value = $(this).val();
356- extension_settings.memory.role = Number(value);
357- reinsertMemory();
358- saveSettingsDebounced();
359-}
360-
361-function onMemorySummaryPromptRoleInput() {
362- const value = $(this).val();
363- extension_settings.memory.summaryPromptRole = Number(value);
364- saveSettingsDebounced();
365-}
366-
367-function onMemoryPositionChange(e) {
368- const value = e.target.value;
369- extension_settings.memory.position = value;
370- reinsertMemory();
371- saveSettingsDebounced();
372-}
373-
374-function onMemoryIncludeWIScanInput() {
375- const value = !!$(this).prop('checked');
376- extension_settings.memory.scan = value;
377- reinsertMemory();
378- saveSettingsDebounced();
379-}
380-
381-function 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-
388-function 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-
395-function 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- */
407-function 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- */
428-function 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- */
449-function 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-
461-function onChatChanged() {
462- const context = getContext();
463- const latestMemory = getLatestMemoryFromChat(context.chat);
464- setMemoryContext(latestMemory, false);
465-}
466-
467-async 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- */
528-async 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- */
557-async 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-
598-async 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- */
622-async 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-
685-async 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.
741-let 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- */
758-async 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-
806-async 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- */
928-async 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-
994-async 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- */
1063-async 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-
1092-function 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-
1109-function onMemoryContentInput() {
1110- const value = $(this).val();
1111- setMemoryContext(value, true);
1112-}
1113-
1114-function onMemoryPromptBuilderInput(e) {
1115- const value = Number(e.target.value);
1116- extension_settings.memory.prompt_builder = value;
1117- saveSettingsDebounced();
1118-}
1119-
1120-function 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- */
1131-function 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-
1154-function 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-
1203-function 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-
1234-export 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-
15-label[for="memory_frozen"],
16-label[for="memory_skipWIAN"] {
17- display: flex;
18- align-items: center;
19- margin: 0 !important;
20-}
21-
22-label[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 @@
1-import { Popper } from '../../../lib.js';
2-import {
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';
21-import {
22- doExtrasFetch,
23- extension_settings,
24- getApiUrl,
25- getContext,
26- modules,
27- renderExtensionTemplateAsync,
28- writeExtensionField,
29-} from '../../extensions.js';
30-import { selected_group } from '../../group-chats.js';
31-import {
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';
46-import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js';
47-import { SECRET_KEYS, secret_state } from '../../secrets.js';
48-import { getNovelAnlas, getNovelUnlimitedImageGeneration, loadNovelSubscriptionData } from '../../nai-settings.js';
49-import { ConnectionManagerRequestService, getMultimodalCaption } from '../shared.js';
50-import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
51-import { SlashCommand } from '../../slash-commands/SlashCommand.js';
52-import {
53- ARGUMENT_TYPE,
54- SlashCommandArgument,
55- SlashCommandNamedArgument,
56-} from '../../slash-commands/SlashCommandArgument.js';
57-import { debounce_timeout, IMAGE_OVERSWIPE, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION, VIDEO_EXTENSIONS } from '../../constants.js';
58-import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
59-import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
60-import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
61-import { ToolManager } from '../../tool-calling.js';
62-import { macros, MacroCategory } from '../../macros/macro-system.js';
63-import { t, translate } from '../../i18n.js';
64-import { oai_settings } from '../../openai.js';
65-import { power_user } from '/scripts/power-user.js';
66-import { MacrosParser } from '/scripts/macros.js';
67-import { ActionLoaderHandle, loader } from '/scripts/action-loader.js';
68-
69-export { MODULE_NAME };
70-
71-const MODULE_NAME = 'sd';
72-// This is a 1x1 transparent PNG
73-const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
74-
75-const 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-};
101-const comfyTypes = {
102- standard: 'standard',
103- runpod_serverless: 'runpod_serverless',
104-};
105-
106-const initiators = {
107- command: 'command',
108- action: 'action',
109- interactive: 'interactive',
110- wand: 'wand',
111- swipe: 'swipe',
112- tool: 'tool',
113-};
114-
115-const 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-
133-const multimodalMap = {
134- [generationMode.CHARACTER]: generationMode.CHARACTER_MULTIMODAL,
135- [generationMode.USER]: generationMode.USER_MULTIMODAL,
136- [generationMode.FACE]: generationMode.FACE_MULTIMODAL,
137-};
138-
139-const 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-
156-const 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-
166-const 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-
178-const 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-
224-const defaultPrefix = 'best quality, absurdres, aesthetic,';
225-const defaultNegative = 'lowres, bad anatomy, bad hands, text, error, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry';
226-
227-const defaultStyles = [
228- {
229- name: 'Default',
230- negative: defaultNegative,
231- prefix: defaultPrefix,
232- },
233-];
234-
235-const placeholderVae = 'Automatic';
236-
237-const 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- */
406-const 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- */
433-function 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- */
448-function 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- */
467-function 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- */
475-function 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- */
484-const 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- */
492-async 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- */
537-async function refreshSettingsUi() {
538- // loadSettings() appends to #sd_style without clearing it, so empty it first.
539- $('#sd_style').empty();
540- await loadSettings();
541-}
542-
543-const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
544-const 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- */
553-function 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-
614-globalThis.SD_ProcessTriggers = processTriggers;
615-
616-function 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-
629-function 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-
640-let 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.
643-let 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- */
650-function 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-
671-async 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- */
864-function 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-
887-async function loadSettingOptions() {
888- return Promise.all([
889- loadSamplers(),
890- loadModels(),
891- loadSchedulers(),
892- loadVaes(),
893- loadLoras(),
894- loadComfyWorkflows(),
895- ]);
896-}
897-
898-async 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-
913-async 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-
935-function 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-
972-function onInteractiveModeInput() {
973- extension_settings.sd.interactive_mode = !!$(this).prop('checked');
974- saveSettingsDebounced();
975-}
976-
977-function onMultimodalCaptioningInput() {
978- extension_settings.sd.multimodal_captioning = !!$(this).prop('checked');
979- saveSettingsDebounced();
980-}
981-
982-function onSnapInput() {
983- extension_settings.sd.snap = !!$(this).prop('checked');
984- saveSettingsDebounced();
985-}
986-
987-function onMinimalPromptProcessing() {
988- extension_settings.sd.minimal_prompt_processing = !!$(this).prop('checked');
989- saveSettingsDebounced();
990-}
991-
992-function 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-
1007-async 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-
1043-async 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-
1081-async 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- */
1126-function 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-
1207-function 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-
1218-function 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- */
1228-const 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- */
1236-let 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- */
1242-function 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- */
1250-function 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-
1314-function onRefImagesEnabledChange() {
1315- extension_settings.sd.ref_images_enabled = !!$(this).prop('checked');
1316- saveSettingsDebounced();
1317-}
1318-
1319-async 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- */
1347-function 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- */
1368-async 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- */
1396-async 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- */
1416-function 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- */
1433-function 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- */
1450-function 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- */
1468-async 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- */
1493-async 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- */
1522-async 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). */
1542-const RUNPOD_POLL_IDLE_MS = 30000;
1543-const RUNPOD_POLL_BUSY_MS = 5000;
1544-/** Frontend heartbeat cadence. The proxy tolerates browser timer pauses for five minutes. */
1545-const RUNPOD_HEARTBEAT_MS = 20000;
1546-
1547-let runpodStatusTimer = null;
1548-let runpodHeartbeatTimer = null;
1549-let runpodLastPhase = 'red';
1550-
1551-function 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- */
1560-async 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- */
1577-async 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- */
1589-function 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). */
1628-function 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-
1646-let runpodPollFailures = 0;
1647-
1648-async 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-
1669-async 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-
1693-function 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. */
1705-function 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-
1722-function 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- */
1733-function 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- */
1742-function 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'). */
1748-function 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- */
1757-function 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- */
1773-function 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- */
1802-async 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- */
1833-function 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-
1870-function 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- */
1884-function 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- */
1937-function 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- */
1947-async 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-
1977-async 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-
2004-async 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-
2031-async 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- */
2059-async 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-
2119-async 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-
2150-async 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-
2161-async 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-
2170-async 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-
2179-function 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-
2193-function 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- */
2214-function 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-
2230-function onRefineModeInput() {
2231- extension_settings.sd.refine_mode = !!$('#sd_refine_mode').prop('checked');
2232- saveSettingsDebounced();
2233-}
2234-
2235-function onFreeExtendInput() {
2236- extension_settings.sd.free_extend = !!$('#sd_free_extend').prop('checked');
2237- saveSettingsDebounced();
2238-}
2239-
2240-function onWandVisibleInput() {
2241- extension_settings.sd.wand_visible = !!$('#sd_wand_visible').prop('checked');
2242- saveSettingsDebounced();
2243-}
2244-
2245-function onCommandVisibleInput() {
2246- extension_settings.sd.command_visible = !!$('#sd_command_visible').prop('checked');
2247- saveSettingsDebounced();
2248-}
2249-
2250-function onInteractiveVisibleInput() {
2251- extension_settings.sd.interactive_visible = !!$('#sd_interactive_visible').prop('checked');
2252- saveSettingsDebounced();
2253-}
2254-
2255-function onToolVisibleInput() {
2256- extension_settings.sd.tool_visible = !!$('#sd_tool_visible').prop('checked');
2257- saveSettingsDebounced();
2258-}
2259-
2260-function 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-
2266-function onSeedInput() {
2267- extension_settings.sd.seed = Number($('#sd_seed').val());
2268- saveSettingsDebounced();
2269-}
2270-
2271-function 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-
2277-function onStepsInput() {
2278- extension_settings.sd.steps = Number($('#sd_steps').val());
2279- $('#sd_steps_value').val(extension_settings.sd.steps);
2280- saveSettingsDebounced();
2281-}
2282-
2283-async 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-
2290-async 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-
2297-function onSamplerChange() {
2298- extension_settings.sd.sampler = $('#sd_sampler').find(':selected').val();
2299- saveSettingsDebounced();
2300-}
2301-
2302-function onADetailerFaceChange() {
2303- extension_settings.sd.adetailer_face = !!$('#sd_adetailer_face').prop('checked');
2304- saveSettingsDebounced();
2305-}
2306-
2307-const 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-
2340-function 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-
2353-function onSchedulerChange() {
2354- extension_settings.sd.scheduler = $('#sd_scheduler').find(':selected').val();
2355- saveSettingsDebounced();
2356-}
2357-
2358-function onWidthInput() {
2359- extension_settings.sd.width = Number($('#sd_width').val());
2360- $('#sd_width_value').val(extension_settings.sd.width);
2361- saveSettingsDebounced();
2362-}
2363-
2364-function onHeightInput() {
2365- extension_settings.sd.height = Number($('#sd_height').val());
2366- $('#sd_height_value').val(extension_settings.sd.height);
2367- saveSettingsDebounced();
2368-}
2369-
2370-function 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-
2380-async 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-
2391-async function onComfyTypeChange() {
2392- extension_settings.sd.comfy_type = $('#sd_comfy_type').find(':selected').val();
2393- await onSourceChange();
2394-}
2395-
2396-function onFunctionToolInput() {
2397- extension_settings.sd.function_tool = !!$(this).prop('checked');
2398- saveSettingsDebounced();
2399- registerFunctionTool();
2400-}
2401-
2402-async function onOpenAiStyleSelect() {
2403- extension_settings.sd.openai_style = String($('#sd_openai_style').find(':selected').val());
2404- saveSettingsDebounced();
2405-}
2406-
2407-async function onOpenAiQualitySelect() {
2408- extension_settings.sd.openai_quality = String($('#sd_openai_quality').find(':selected').val());
2409- saveSettingsDebounced();
2410-}
2411-
2412-async function onOpenAiDurationSelect() {
2413- extension_settings.sd.openai_duration = String($('#sd_openai_duration').find(':selected').val());
2414- saveSettingsDebounced();
2415-}
2416-
2417-async 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-
2431-function onNovelAnlasGuardInput() {
2432- extension_settings.sd.novel_anlas_guard = !!$('#sd_novel_anlas_guard').prop('checked');
2433- saveSettingsDebounced();
2434-}
2435-
2436-function 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-
2447-function onNovelSmDynInput() {
2448- extension_settings.sd.novel_sm_dyn = !!$('#sd_novel_sm_dyn').prop('checked');
2449- saveSettingsDebounced();
2450-}
2451-
2452-function onNovelDecrisperInput() {
2453- extension_settings.sd.novel_decrisper = !!$('#sd_novel_decrisper').prop('checked');
2454- saveSettingsDebounced();
2455-}
2456-
2457-function onNovelVarietyBoostInput() {
2458- extension_settings.sd.novel_variety_boost = !!$('#sd_novel_variety_boost').prop('checked');
2459- saveSettingsDebounced();
2460-}
2461-
2462-function onPollinationsEnhanceInput() {
2463- extension_settings.sd.pollinations_enhance = !!$('#sd_pollinations_enhance').prop('checked');
2464- saveSettingsDebounced();
2465-}
2466-
2467-function onHordeNsfwInput() {
2468- extension_settings.sd.horde_nsfw = !!$(this).prop('checked');
2469- saveSettingsDebounced();
2470-}
2471-
2472-function onHordeKarrasInput() {
2473- extension_settings.sd.horde_karras = !!$(this).prop('checked');
2474- saveSettingsDebounced();
2475-}
2476-
2477-function onHordeSanitizeInput() {
2478- extension_settings.sd.horde_sanitize = !!$(this).prop('checked');
2479- saveSettingsDebounced();
2480-}
2481-
2482-function onRestoreFacesInput() {
2483- extension_settings.sd.restore_faces = !!$(this).prop('checked');
2484- saveSettingsDebounced();
2485-}
2486-
2487-function onHighResFixInput() {
2488- extension_settings.sd.enable_hr = !!$(this).prop('checked');
2489- saveSettingsDebounced();
2490-}
2491-
2492-function onAutoUrlInput() {
2493- extension_settings.sd.auto_url = $('#sd_auto_url').val();
2494- saveSettingsDebounced();
2495-}
2496-
2497-function onAutoAuthInput() {
2498- extension_settings.sd.auto_auth = $('#sd_auto_auth').val();
2499- saveSettingsDebounced();
2500-}
2501-
2502-function onSdcppUrlInput() {
2503- extension_settings.sd.sdcpp_url = $('#sd_sdcpp_url').val();
2504- saveSettingsDebounced();
2505-}
2506-
2507-function onVladUrlInput() {
2508- extension_settings.sd.vlad_url = $('#sd_vlad_url').val();
2509- saveSettingsDebounced();
2510-}
2511-
2512-function onVladAuthInput() {
2513- extension_settings.sd.vlad_auth = $('#sd_vlad_auth').val();
2514- saveSettingsDebounced();
2515-}
2516-
2517-function onDrawthingsUrlInput() {
2518- extension_settings.sd.drawthings_url = $('#sd_drawthings_url').val();
2519- saveSettingsDebounced();
2520-}
2521-
2522-function onDrawthingsAuthInput() {
2523- extension_settings.sd.drawthings_auth = $('#sd_drawthings_auth').val();
2524- saveSettingsDebounced();
2525-}
2526-
2527-function onHrUpscalerChange() {
2528- extension_settings.sd.hr_upscaler = $('#sd_hr_upscaler').find(':selected').val();
2529- saveSettingsDebounced();
2530-}
2531-
2532-function 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-
2538-function 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-
2544-function 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-
2550-function 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-
2556-function onComfyUrlInput() {
2557- extension_settings.sd.comfy_url = String($('#sd_comfy_url').val());
2558- saveSettingsDebounced();
2559-}
2560-
2561-function onComfyRunPodUrlInput() {
2562- extension_settings.sd.comfy_runpod_url = String($('#sd_comfy_runpod_url').val());
2563- saveSettingsDebounced();
2564-}
2565-
2566-function 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- */
2577-function 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-
2585-async 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-
2594-function 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-
2615-function onBflUpsamplingInput() {
2616- extension_settings.sd.bfl_upsampling = !!$('#sd_bfl_upsampling').prop('checked');
2617- saveSettingsDebounced();
2618-}
2619-
2620-function onStabilityStylePresetChange() {
2621- extension_settings.sd.stability_style_preset = String($('#sd_stability_style_preset').val());
2622- saveSettingsDebounced();
2623-}
2624-
2625-async 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-
2637-async 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-
2660-async 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-
2683-async 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-
2706-async 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-
2729-async 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-
2753-async 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-
2777-async 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-
2817-async 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-
2836-async 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-
2855-async function onVaeChange() {
2856- extension_settings.sd.vae = $('#sd_vae').find(':selected').val();
2857-}
2858-
2859-async 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-
2878-async 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-
2897-async 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-
2916-async 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-
2937-async 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-
2956-async 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-
2969-async 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-
3062-async 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-
3075-async 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-
3092-async 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-
3114-async 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-
3142-async 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-
3147-async 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-
3163-async 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-
3185-async 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-
3197-async 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-
3226-async 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- */
3330-function 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- */
3348-function 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-
3387-async 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-
3397-async 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-
3408-async 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-
3423-async 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-
3430-async 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-
3457-async 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-
3472-async 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-
3490-async 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-
3511-async 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-
3544-async 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-
3562-async 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-
3581-async 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-
3606-async 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-
3648-async 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-
3684-async 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-
3699-async 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-
3716-async 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-
3758-async 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-
3791-async 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-
3816-async 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-
3825-async 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-
3838-function loadNovelSchedulers() {
3839- return ['karras', 'native', 'exponential', 'polyexponential'];
3840-}
3841-
3842-async 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-
3875-async 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-
3968-async 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-
3997-async 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-
4002-async 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-
4098-async 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-
4122-async 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-
4156-async 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-
4182-function 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-
4215-function 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- */
4235-function 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-
4269-function 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- */
4306-function 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- */
4329-async 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- */
4445-function 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- */
4493-function 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- */
4507-async 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- */
4544-function 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- */
4574-async 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-
4611-function 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-
4625-function 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- */
4634-async 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- */
4691-async 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- */
4754-async 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- */
4954-async 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- */
4985-async 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- */
5018-async 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- */
5062-function 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- */
5138-async 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- */
5205-async 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- */
5251-async 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- */
5291-async 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- */
5366-async 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- */
5416-async 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- */
5457-async 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- */
5496-function 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- */
5567-async 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- */
5670-async 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- */
5715-async 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- */
5813-async 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- */
5840-async 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- */
5857-async 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- */
5884-async 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- */
5915-async 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- */
5946-async 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- */
5980-async 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- */
6013-async 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- */
6052-async 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- */
6085-async 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- */
6151-async 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- */
6231-async 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-
6252-async 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-
6279-async 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-
6372-async 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-
6399-async 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-
6419-async 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- */
6481-async 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- */
6526-function 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-
6541-async 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- */
6608-function 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-
6626-function 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