Flow rinse: browser downloads, fast-fail, autoStart, launcher + self-healing launchd

- background.js: chrome.downloads via page session (no CSP/base64)
- content.js: fast-fail on Flow error banner, image vs video timeouts, autoStart
- local_server_example.py: /enqueue + /save, downloads/ output, resume-skip
- launch.sh: idempotent server-up + caffeinate + single-tab focus
- install.sh + plist: self-healing agent every 10 min (TCC-safe App Support copy)
- flowclient.py: enqueue/wait_for helper for game projects
- README: full setup + macOS TCC gotcha

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-06 01:20:32 +10:00
parent 7ae0a73db0
commit 501431bf59
12 changed files with 388 additions and 30 deletions

3
.gitignore vendored
View File

@ -2,8 +2,9 @@
__pycache__/
*.pyc
# local runtime output (harvested URLs)
# local runtime output (harvested URLs + downloaded assets)
results.jsonl
downloads/
# saved SPA pages can embed auth tokens / cookies — never commit
Google Flow*.html

66
README.md Normal file
View File

@ -0,0 +1,66 @@
# Flow Auto-Pilot
A Brave extension + local queue that drains a prompt bank through Google Labs
**Flow** (Nano Banana 2 images, Veo videos) on the webui-only credits you can't
reach via API. The extension types prompts into the Flow agent box with
**trusted CDP input** (Flow ignores synthetic DOM events), waits for the media
grid to grow, harvests the new asset URLs, and downloads them with the page's
session cookies.
Not headless: Brave must be running and logged into the Flow account. But once
the tab is open with **Auto-start** ticked, it runs unattended.
## Parts
| File | Role |
|---|---|
| `manifest.json`, `background.js`, `content.js`, `popup.*` | the Brave MV3 extension |
| `local_server_example.py` | queue server on `:8017``queue.jsonl` in, `results.jsonl` out |
| `queue.jsonl` | the prompt bank (one task per line) |
| `flowclient.py` | `enqueue()` / `wait_for()` so a game project can request assets |
| `launch.sh` | start server + keep Mac awake + open/focus one Flow tab |
| `install.sh` + `games.monsterrobot.flowrinse.plist` | self-healing launchd agent |
## Run it by hand
```
./launch.sh https://labs.google/fx/tools/flow/project/<PROJECT_ID>
```
Server comes up (under `caffeinate`), Brave focuses the Flow tab, and with
Auto-start ticked the rinse begins. Files land in
`~/Downloads/flowrinse/<category>/`.
## Run it unattended (launchd)
```
./install.sh
```
Fires `launch.sh` at login and every 10 min. Because `launch.sh` is idempotent
(port check + tab reuse), the repeat runs are no-ops that self-heal a dead
server or a closed tab.
**macOS TCC gotcha** (same as the vault-backup job): launchd can't execute
scripts inside `~/Documents`, so `install.sh` runs a **copy** at
`~/Library/Application Support/flowrinse/`. That copy owns the live
`queue.jsonl` / `results.jsonl` / `downloads/` once the agent is running.
Re-run `./install.sh` after editing `launch.sh` or `local_server_example.py`
to resync the copy. First run pops one Automation prompt (allow control of
Brave) — click Allow on the rig's screen.
```
launchctl kickstart -k gui/$(id -u)/games.monsterrobot.flowrinse # run now
launchctl bootout gui/$(id -u)/games.monsterrobot.flowrinse # stop it
```
## Enqueue from another project
```python
from flowclient import enqueue
enqueue("Sierra EGA record-store back room, 160x168, 16-color",
kind="image", category="morp2_bg")
```
Or over the wire: `POST http://localhost:8017/enqueue` with
`{"prompt","kind","model","category","dest"}`.

View File

@ -1,8 +1,9 @@
// Background service worker: performs TRUSTED input via the Chrome DevTools Protocol.
// Flow's agent editor ignores synthetic DOM events (isTrusted=false) — the text shows
// but the app's state stays empty ("no prompt" on send). CDP Input.* events are trusted,
// so the framework actually registers them. Requires the "debugger" permission; the user
// sees a "started debugging this browser" banner while it runs (expected).
// Background service worker.
// * Trusted input via the Chrome DevTools Protocol — Flow's editor ignores synthetic
// DOM events (isTrusted=false); CDP Input.* events are trusted so it registers them.
// * Asset download — fetches the harvested media URL with the page's session cookies
// (host_permission covers labs.google) and hands the bytes to the local server. Runs
// in the worker, so it's not blocked by the page's CSP.
const attached = new Set();
@ -20,7 +21,6 @@ async function ensure(tabId) {
if (attached.has(tabId)) return;
try { await attach(tabId); attached.add(tabId); }
catch (e) {
// "Another debugger is already attached" => DevTools open on the tab; treat as attached-ish
if (/already attached/i.test(String(e.message || e))) attached.add(tabId);
else throw e;
}
@ -32,15 +32,22 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const tabId = sender.tab && sender.tab.id;
(async () => {
try {
if (!tabId) throw new Error('no tab id');
await ensure(tabId);
if (msg.type === 'insertText') {
await ensure(tabId);
await cmd(tabId, 'Input.insertText', { text: msg.text });
sendResponse({ ok: true });
} else if (msg.type === 'pressEnter') {
await ensure(tabId);
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ENTER });
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ENTER });
sendResponse({ ok: true });
} else if (msg.type === 'download') {
// Browser-native download: uses the page's session cookies, follows the media
// redirect, no CSP/CORS/base64. Lands under the browser's Downloads dir.
const dlId = await new Promise((res, rej) => chrome.downloads.download(
{ url: msg.url, filename: msg.filename, conflictAction: 'uniquify', saveAs: false },
(id) => chrome.runtime.lastError ? rej(chrome.runtime.lastError) : res(id)));
sendResponse({ ok: dlId != null, id: dlId });
} else if (msg.type === 'detach') {
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
sendResponse({ ok: true });
@ -51,7 +58,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: String(e.message || e) });
}
})();
return true; // keep the message channel open for the async response
return true; // async response
});
chrome.debugger.onDetach.addListener((src) => { if (src.tabId) attached.delete(src.tabId); });

View File

@ -15,6 +15,7 @@
let settings = {
serverUrl: 'http://localhost:8017',
isRunning: false,
autoStart: false, // begin polling the moment the Flow tab loads (for scripted launch)
// Optional CSS overrides — leave blank to use the resilient generic finders.
promptSel: '',
submitSel: '',
@ -29,6 +30,10 @@ let generationInProgress = false;
function init() {
chrome.storage.local.get(settings, (data) => {
settings = { ...settings, ...data };
if (settings.autoStart && !settings.isRunning) {
settings.isRunning = true;
chrome.storage.local.set({ isRunning: true }); // so the popup reflects it
}
log('system', `Content script connected (${settings.isRunning ? 'ACTIVE' : 'idle'}).`);
if (settings.isRunning) startPolling();
});
@ -165,14 +170,40 @@ function clickConfirmIfPresent() {
function snapshotAssets() {
const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document;
const urls = new Set();
const push = (u) => { if (u && /^https?:/.test(u) && !/^data:/.test(u)) urls.add(u.split('#')[0]); };
const ok = (u) => u && !/^data:/.test(u) && (/^https?:/.test(u) || /^blob:/.test(u));
const push = (u) => { if (ok(u)) urls.add(u.split('#')[0]); };
scope.querySelectorAll('video[src]').forEach(v => push(v.src));
scope.querySelectorAll('video source[src]').forEach(s => push(s.src));
scope.querySelectorAll('a[href*="googleusercontent"], a[download][href]').forEach(a => push(a.href));
scope.querySelectorAll('img[src]').forEach(i => { if (/googleusercontent|lh3|storage/.test(i.src)) push(i.src); });
scope.querySelectorAll('video[poster]').forEach(v => push(v.poster));
scope.querySelectorAll('a[href]').forEach(a => {
if (a.hasAttribute('download') || /googleusercontent|media\.getMediaUrl|\/media\//i.test(a.href)) push(a.href);
});
// images: match media hosts + the redirect API + blobs (diffed before/after, so only NEW ones count)
scope.querySelectorAll('img[src]').forEach(i => {
if (/googleusercontent|lh3|ggpht|storage|media\.getMediaUrl|^blob:/i.test(i.src)) push(i.src);
});
return urls;
}
// Detect Flow's own failure banner so we can skip fast instead of waiting the full timeout.
function flowError() {
return [...document.querySelectorAll('*')].some(e => visible(e) &&
/something went wrong|please try again|couldn'?t generate|failed to generate|generation failed/i.test(e.textContent || '') &&
(e.textContent || '').length < 90);
}
// Poll for: a new asset (win), a Flow error banner (fast-fail), or timeout.
// The grace period avoids catching a stale error banner from the previous turn.
async function waitForResultOrError(baseCount, maxMs) {
const t0 = Date.now();
while (Date.now() - t0 < maxMs) {
if (snapshotAssets().size > baseCount) return 'asset';
if (Date.now() - t0 > 8000 && flowError()) return 'error';
await sleep(2500);
}
return null;
}
// ---- queue loop ----------------------------------------------------------
function startPolling() {
@ -217,18 +248,31 @@ async function executeTask(task) {
await sleep(1500);
log('info', 'Submitted. Waiting for generation…');
// Wait until a NEW asset appears AND the asset count stops growing.
const appeared = await waitFor(() => snapshotAssets().size > before.size, settings.genTimeoutMs, 2500);
if (!appeared) {
// last-chance: a quota/credit wall?
const wall = [...document.querySelectorAll('*')].some(e => visible(e) && /credit|quota|limit|run out/i.test(e.textContent || '') && (e.textContent || '').length < 120);
throw new Error(wall ? 'Stopped: credits/quota wall detected.' : 'Timed out waiting for output.');
// Wait for a new asset, a Flow error (fast-fail), or timeout. Images should be quick.
const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs;
const outcome = await waitForResultOrError(before.size, timeoutMs);
if (outcome === 'error') throw new Error('Flow errored ("Something went wrong") — skipping.');
if (outcome !== 'asset') {
const wall = [...document.querySelectorAll('*')].some(e => visible(e) &&
/out of credits|no credits|insufficient|quota exceeded|run out of|daily limit|reached your limit/i.test(e.textContent || '') &&
(e.textContent || '').length < 160);
throw new Error(wall ? 'Real credit/quota wall detected.' : 'Timed out — no new asset (harvest miss or Flow queue).');
}
await waitForStable(() => snapshotAssets().size, { min: before.size + 1 });
const after = snapshotAssets();
const fresh = [...after].filter(u => !before.has(u));
log('success', `Harvested ${fresh.length} asset(s).`);
// Browser download — uses your session, no CSP/CORS. Lands in Downloads/flowrinse/<category>/.
const ext = task.kind === 'video' ? 'mp4' : 'png';
const cat = (task.category || 'misc').replace(/[^a-z0-9_\-]/gi, '_');
let saved = 0;
for (let i = 0; i < fresh.length; i++) {
const r = await bg({ type: 'download', url: fresh[i], filename: `flowrinse/${cat}/${task.id}_${i}.${ext}` });
if (r && r.ok) saved++;
}
if (fresh.length) log(saved === fresh.length ? 'success' : 'info', `Downloaded ${saved}/${fresh.length} → Downloads/flowrinse/${cat}/`);
await reportCompletion(task.id, 'success', null, fresh);
} catch (err) {
log('error', `Task ${task.id} failed: ${err.message}`);

49
flowclient.py Normal file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Tiny client so OpShopGame / 90sDJsim can REQUEST Flow assets and RETRIEVE them.
The running Flow extension is the bridge to Google the games never touch it.
from flowclient import enqueue, wait_for
r = enqueue("Low-poly vinyl crate icon, transparent bg, 90s UI",
kind="image", category="djsim_ui",
dest="/Users/johnking/Documents/90sDJsim/assets/ui")
path = wait_for(r["dest"], r["id"]) # blocks until the file lands
Request -> appended to the queue; the extension drains it, generates, downloads.
Retrieve -> the file appears in <dest> (and in flowext/downloads/<category>/).
"""
import json, os, sys, time, urllib.request
SERVER = os.environ.get("FLOW_SERVER", "http://localhost:8017")
def enqueue(prompt, kind="image", model=None, category="request", dest=None, server=SERVER):
body = json.dumps({"prompt": prompt, "kind": kind, "model": model,
"category": category, "dest": dest}).encode()
req = urllib.request.Request(server + "/enqueue", body, {"Content-Type": "application/json"})
out = json.load(urllib.request.urlopen(req, timeout=10)) # {"ok":True,"id":...}
out["dest"] = dest
return out
def wait_for(dest, task_id, timeout=1800, poll=10):
"""Block until a file for task_id lands in <dest>; return its path (or None)."""
if not dest:
return None
end = time.time() + timeout
while time.time() < end:
if os.path.isdir(dest):
for f in sorted(os.listdir(dest)):
if f.startswith(task_id):
return os.path.join(dest, f)
time.sleep(poll)
return None
if __name__ == "__main__":
if "--demo" in sys.argv:
print(enqueue("Low-poly flat-shaded vinyl record crate icon, transparent background, 90s UI",
kind="image", category="djsim_ui"))
else:
print(__doc__)

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>games.monsterrobot.flowrinse</string>
<!-- sh -c so $HOME expands; launchd doesn't expand ~ in ProgramArguments -->
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>exec "$HOME/Library/Application Support/flowrinse/launch.sh"</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>FLOW_URL</key>
<string>https://labs.google/fx/tools/flow/project/4085fe36-7540-4359-810f-12c69506f78d</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<!-- self-healing: idempotent launch.sh every 10 min (nc check + tab reuse
make repeat runs no-ops), so a dead server or closed tab comes back -->
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>600</integer>
<key>StandardOutPath</key>
<string>/tmp/flowrinse.launchd.log</string>
<key>StandardErrorPath</key>
<string>/tmp/flowrinse.launchd.log</string>
</dict>
</plist>

40
install.sh Normal file
View File

@ -0,0 +1,40 @@
#!/bin/sh
# Install (or resync) the self-healing launchd agent for the Flow rinse.
# Run ON the rig Mac, from this directory: ./install.sh
#
# Mirrors the vault-backup pattern (mrpgi-vault/backup.sh): launchd can't
# execute scripts inside ~/Documents (macOS TCC blocks it), so the agent runs
# a COPY of the runtime at "~/Library/Application Support/flowrinse/".
# Once the agent owns the server, queue.jsonl / results.jsonl / downloads
# live THERE — enqueue via http://localhost:8017/enqueue as usual.
#
# Re-run after editing launch.sh / local_server_example.py to resync the copy.
#
# One-time macOS prompts on first run (click Allow on the rig's screen):
# * Automation: "sh"/"osascript" wants to control "Brave Browser"
#
# Manage the job:
# launchctl kickstart -k gui/$(id -u)/games.monsterrobot.flowrinse # run now
# launchctl bootout gui/$(id -u)/games.monsterrobot.flowrinse # stop it
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
APP="$HOME/Library/Application Support/flowrinse"
LABEL=games.monsterrobot.flowrinse
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
# runtime copy (code always refreshed; live state never overwritten)
mkdir -p "$APP"
cp "$DIR/launch.sh" "$DIR/local_server_example.py" "$DIR/flowclient.py" "$APP/"
chmod +x "$APP/launch.sh"
for f in queue.jsonl results.jsonl; do
[ -f "$APP/$f" ] || { [ -f "$DIR/$f" ] && cp "$DIR/$f" "$APP/$f" || true; }
done
mkdir -p "$HOME/Library/LaunchAgents"
cp "$DIR/$LABEL.plist" "$PLIST"
launchctl bootout gui/$(id -u)/$LABEL 2>/dev/null || true
launchctl bootstrap gui/$(id -u) "$PLIST"
echo "installed $LABEL — fires at load + every 10 min"
echo "run now: launchctl kickstart -k gui/$(id -u)/$LABEL"

57
launch.sh Executable file
View File

@ -0,0 +1,57 @@
#!/bin/bash
# One-command Flow rinse launcher (macOS). Not headless — Brave must be logged in —
# but fully unattended once the tab is open and Auto-start is ticked in the popup.
#
# ./launch.sh https://labs.google/fx/tools/flow/project/XXXXXXXX
# FLOW_URL=... ./launch.sh
#
# Does three things: start the queue server if it's down, keep the Mac awake, and
# open OR focus a single Flow tab in Brave (never spawns duplicates).
#
# Scheduled every 10 min via launchd for self-healing (see install.sh).
# NOTE: launchd can't execute scripts inside ~/Documents (macOS TCC blocks it),
# so the agent runs a COPY at "~/Library/Application Support/flowrinse/" —
# after editing this file or local_server_example.py, re-run ./install.sh to resync.
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
URL="${1:-$FLOW_URL}"
if [ -z "$URL" ]; then
echo "Usage: ./launch.sh <flow_project_url> (or set FLOW_URL)"
exit 1
fi
# 1. queue server on 8017 (nc check avoids consuming a task via /next-task)
if ! nc -z localhost 8017 2>/dev/null; then
( cd "$DIR" && caffeinate -s python3 -B local_server_example.py >/tmp/flowrinse.log 2>&1 & )
echo "started queue server + caffeinate (log: /tmp/flowrinse.log)"
sleep 1
else
echo "queue server already up on 8017"
fi
# 2. open or focus ONE Flow tab (reuses an existing one so it can't double-run).
# Only `activate` (steal system focus) when opening fresh — on the 10-min launchd
# tick an existing tab is re-selected inside Brave without yanking you out of
# whatever you're doing. Selecting the tab also keeps Memory Saver from discarding it.
osascript <<OSA
tell application "Brave Browser"
set found to false
repeat with w in windows
set i to 0
repeat with t in tabs of w
set i to i + 1
if (URL of t) contains "tools/flow/project" then
set active tab index of w to i
set index of w to 1
set found to true
end if
end repeat
end repeat
if not found then
activate
open location "$URL"
end if
end tell
OSA
echo "Brave focused on Flow. With Auto-start ticked, the rinse begins on load."

View File

@ -13,12 +13,50 @@ Behaviour:
Harvested `assets` are googleusercontent URLs; they need your logged-in session to download,
so this just records them. ponytail: fetch+save the blobs from the extension later if you want local files.
"""
import json, os, time
import base64, json, os, time
from http.server import HTTPServer, BaseHTTPRequestHandler
HERE = os.path.dirname(os.path.abspath(__file__))
QUEUE_FILE = os.path.join(HERE, "queue.jsonl")
RESULTS_FILE = os.path.join(HERE, "results.jsonl")
DOWNLOADS = os.path.join(HERE, "downloads")
EXT = {"video/mp4": "mp4", "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"}
def save_asset(rec):
"""Write downloaded bytes to downloads/<category>/<id>_<idx>.<ext> (+ dest copy)."""
data = base64.b64decode(rec.get("dataBase64", ""))
ct = (rec.get("contentType") or "").split(";")[0].strip()
ext = EXT.get(ct, "mp4" if "video" in ct else "png")
name = f'{rec.get("id", "asset")}_{rec.get("idx", 0)}.{ext}'
outdir = os.path.join(DOWNLOADS, rec.get("category") or "misc")
os.makedirs(outdir, exist_ok=True)
path = os.path.join(outdir, name)
with open(path, "wb") as f:
f.write(data)
dest = rec.get("dest") # game asked for a copy in its own folder
if dest:
os.makedirs(dest, exist_ok=True)
with open(os.path.join(dest, name), "wb") as f:
f.write(data)
return path
def enqueue_task(rec):
"""Append a game-requested task to the queue (persisted, picked up on next poll)."""
t = {
"id": rec.get("id") or f'req_{int(time.time() * 1000)}',
"kind": rec.get("kind", "image"),
"model": rec.get("model") or ("Veo 3.1 - Fast" if rec.get("kind") == "video" else "Nano Banana 2"),
"prompt": rec["prompt"],
"category": rec.get("category", "request"),
}
if rec.get("dest"):
t["dest"] = rec["dest"]
with open(QUEUE_FILE, "a") as f:
f.write(json.dumps(t) + "\n")
return t
# Fallback demo queue if queue.jsonl is absent (so it runs out of the box).
DEMO = [
@ -80,17 +118,28 @@ class Handler(BaseHTTPRequestHandler):
return self._send(204) # queue drained
def do_POST(self):
if self.path != "/task-completed":
return self._send(404, {"error": "not found"})
n = int(self.headers.get("Content-Length", 0))
rec = json.loads(self.rfile.read(n).decode() or "{}")
rec["ts"] = time.time()
INFLIGHT.pop(rec.get("id"), None)
with open(RESULTS_FILE, "a") as f:
f.write(json.dumps(rec) + "\n")
assets = rec.get("assets") or []
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(assets)} asset(s)")
self._send(200, {"ok": True})
if self.path == "/task-completed":
rec["ts"] = time.time()
INFLIGHT.pop(rec.get("id"), None)
with open(RESULTS_FILE, "a") as f:
f.write(json.dumps(rec) + "\n")
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(rec.get('assets') or [])} asset(s)")
return self._send(200, {"ok": True})
if self.path == "/save":
path = save_asset(rec)
print(f"[SERVER] saved {rec.get('id')} -> {path}")
return self._send(200, {"ok": True, "path": path})
if self.path == "/enqueue":
t = enqueue_task(rec)
print(f"[SERVER] enqueued {t['id']} ({t['kind']})")
return self._send(200, {"ok": True, "id": t["id"]})
return self._send(404, {"error": "not found"})
def log_message(self, *a): # quiet the default noise
pass

View File

@ -5,7 +5,8 @@
"description": "Automate prompt execution, model selection, and video/image generation using local queue integration.",
"permissions": [
"storage",
"debugger"
"debugger",
"downloads"
],
"background": {
"service_worker": "background.js"

View File

@ -37,6 +37,10 @@
<button id="toggle-btn" class="btn btn-primary start-state">Start Automation</button>
</div>
<label class="form-group" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="auto-start"> Auto-start when the Flow tab opens
</label>
<!-- Console / Activity Logs -->
<div class="console-section">
<div class="console-header">

View File

@ -5,6 +5,7 @@ const DEFAULT_SETTINGS = {
promptSel: '',
submitSel: '',
resultSel: '',
autoStart: false,
pollInterval: 4000,
logs: []
};
@ -16,6 +17,7 @@ const globalStatus = document.getElementById('global-status');
const statusLabel = globalStatus.querySelector('.status-label');
const logConsole = document.getElementById('log-console');
const clearLogsBtn = document.getElementById('clear-logs');
const autoStartInput = document.getElementById('auto-start');
// Advanced inputs
const selPromptInput = document.getElementById('sel-prompt');
@ -26,6 +28,7 @@ const pollIntervalInput = document.getElementById('poll-interval');
// Load settings on startup
chrome.storage.local.get(DEFAULT_SETTINGS, (settings) => {
serverUrlInput.value = settings.serverUrl;
autoStartInput.checked = !!settings.autoStart;
selPromptInput.value = settings.promptSel;
selGenerateInput.value = settings.submitSel;
selModelInput.value = settings.resultSel;
@ -95,6 +98,7 @@ function saveSetting(key, value) {
}
serverUrlInput.addEventListener('change', (e) => saveSetting('serverUrl', e.target.value));
autoStartInput.addEventListener('change', (e) => saveSetting('autoStart', e.target.checked));
selPromptInput.addEventListener('change', (e) => saveSetting('promptSel', e.target.value));
selGenerateInput.addEventListener('change', (e) => saveSetting('submitSel', e.target.value));
selModelInput.addEventListener('change', (e) => saveSetting('resultSel', e.target.value));