diff --git a/ui/src/dash/settings.jsx b/ui/src/dash/settings.jsx index c17c1f28..b72fb088 100644 --- a/ui/src/dash/settings.jsx +++ b/ui/src/dash/settings.jsx @@ -856,24 +856,45 @@ function VoiceSection() { // Image-gen exposes enable/engine(provider)/model picks for the img.img slot. // Persisted via POST /api/capabilities/img/img {model, provider, enabled}. // -// Deferred (#554 follow-up — no clean slot-config path): -// - Default size (width × height): read from /v1/images/generations body, not slot config. -// - Steps: from extra_body.steps per-request; template defaults in workflows JSON. -// - ComfyUI workflow selection: bound at inference time by model_class from the registry. -// These require either per-request defaults in slot TOML (not yet modelled) or a -// new /api/slots/{name}/defaults surface — tracked in the issue body. +// Generation defaults (#599 ImageGenConfig — [image] table on the img slot +// TOML): default_size / default_steps / idle_restore_minutes. These persist +// via PUT /api/slots/{name}/config { image: {...} } through useSlotEdit, +// mirroring how the Voice section wires TTS default_voice. The img slot name +// is discovered from useSlots (type "image" / name "img"); when no img slot +// exists the controls degrade to disabled with a hint. function ImageGenSection() { const capsQuery = useCapabilities(); const applyCapability = useCapabilityApply(); + const slotsQuery = useSlots(); + const editSlot = useSlotEdit(); const caps = capsQuery.data; const imgCatalogs = caps?.catalogs?.img || {}; const imgSelections = caps?.selections?.img || {}; const imgSelection = imgSelections.img || {}; + // Discover the img slot name so the [image] config read/write targets a real + // slot. Prefer an explicit "img" name, else the first image-type slot. + const imgSlotName = + (slotsQuery.data || []).find(s => s.name === "img" || s.type === "image" || s.group === "img")?.name || null; + const imgCfgQuery = useSlotConfig(imgSlotName); + const imgCfgImage = (imgCfgQuery.data?.image) || {}; + + // Schema defaults (ImageGenConfig): an all-defaults [image] table is elided + // from the dumped config, so fall back to the same defaults the backend uses. + const DEF_SIZE = "1024x1024"; + const DEF_STEPS = "0"; + const DEF_IDLE = "60"; + const origSize = imgCfgImage.default_size != null ? String(imgCfgImage.default_size) : DEF_SIZE; + const origSteps = imgCfgImage.default_steps != null ? String(imgCfgImage.default_steps) : DEF_STEPS; + const origIdle = imgCfgImage.idle_restore_minutes != null ? String(imgCfgImage.idle_restore_minutes) : DEF_IDLE; + const [imgModel, setImgModel] = useStateSet(""); const [imgEnabled, setImgEnabled] = useStateSet(false); const [imgProvider, setImgProvider] = useStateSet(""); + const [defaultSize, setDefaultSize] = useStateSet(DEF_SIZE); + const [defaultSteps, setDefaultSteps] = useStateSet(DEF_STEPS); + const [idleRestore, setIdleRestore] = useStateSet(DEF_IDLE); useEffectSet(() => { if (imgSelection.model != null) setImgModel(imgSelection.model || ""); @@ -881,7 +902,15 @@ function ImageGenSection() { if (imgSelection.provider != null) setImgProvider(imgSelection.provider || ""); }, [imgSelection.model, imgSelection.enabled, imgSelection.provider]); + useEffectSet(() => { + const img = imgCfgQuery.data?.image || {}; + setDefaultSize(img.default_size != null ? String(img.default_size) : DEF_SIZE); + setDefaultSteps(img.default_steps != null ? String(img.default_steps) : DEF_STEPS); + setIdleRestore(img.idle_restore_minutes != null ? String(img.idle_restore_minutes) : DEF_IDLE); + }, [imgCfgQuery.data]); + const imgDirty = imgModel !== (imgSelection.model || "") || imgEnabled !== !!imgSelection.enabled || imgProvider !== (imgSelection.provider || ""); + const defaultsDirty = !!imgSlotName && (defaultSize !== origSize || defaultSteps !== origSteps || idleRestore !== origIdle); const imgCatalogItems = imgCatalogs.img?.items || imgCatalogs.img?.models || []; const imgStatus = imgSelection.status || "offline"; @@ -896,6 +925,29 @@ function ImageGenSection() { } }; + const doSaveDefaults = async () => { + if (!imgSlotName) return; + // Coerce to the ImageGenConfig field types before writing. Steps / idle are + // non-negative ints (schema ge=0); size is a freeform "WxH" string. + const image = { + default_size: defaultSize.trim() || DEF_SIZE, + default_steps: Math.max(0, parseInt(defaultSteps, 10) || 0), + idle_restore_minutes: Math.max(0, parseInt(idleRestore, 10) || 0), + }; + try { + await editSlot.mutateAsync({ name: imgSlotName, body: { image } }); + window.__hal0Toast && window.__hal0Toast("Image-gen defaults saved", "ok"); + } catch (e) { + window.__hal0Toast && window.__hal0Toast(`Save failed — ${e?.message || "see logs"}`, "err"); + } + }; + + const resetDefaults = () => { + setDefaultSize(origSize); + setDefaultSteps(origSteps); + setIdleRestore(origIdle); + }; + const statusChip = (st) => { const color = st === "ready" || st === "serving" ? "var(--ok)" : st === "starting" || st === "warming" ? "var(--warn)" : "var(--fg-4)"; return {st}; @@ -938,8 +990,6 @@ function ImageGenSection() { ) } sub={imgCatalogItems.length === 0 ? "no installed image models — install one in the Models view" : undefined} /> - {/* Size / Steps / Workflow are per-request params (extra_body.*), not slot config — hidden until /api/slots/{name}/defaults lands */} -
{imgDirty && (
+ + {/* ── Generation defaults (#599 ImageGenConfig — [image] on the img slot) ── */} +
+
+
+ Generation defaults + img slot config · applied when a /v1/images request omits the param +
+
+ {imgSlotName + ? {imgSlotName} + : no img slot} +
+
+ setDefaultSize(e.target.value)} placeholder={DEF_SIZE} + disabled={!imgSlotName} + className="mono" style={{background: "var(--bg-2)", color: "var(--fg)", border: "1px solid var(--line)", borderRadius: 4, padding: "3px 6px", fontSize: 11, width: 140}} /> + } /> + setDefaultSteps(e.target.value)} placeholder={DEF_STEPS} + disabled={!imgSlotName} + className="mono" style={{background: "var(--bg-2)", color: "var(--fg)", border: "1px solid var(--line)", borderRadius: 4, padding: "3px 6px", fontSize: 11, width: 100}} /> + } /> + setIdleRestore(e.target.value)} placeholder={DEF_IDLE} + disabled={!imgSlotName} + className="mono" style={{background: "var(--bg-2)", color: "var(--fg)", border: "1px solid var(--line)", borderRadius: 4, padding: "3px 6px", fontSize: 11, width: 100}} /> + } /> + {!imgSlotName && ( +
+ No img slot configured — create one in the Slots view to edit generation defaults. +
+ )} +
+ {defaultsDirty && ( + + )} + +
+
); } @@ -1027,13 +1120,67 @@ function DefaultSlotsSection() { } function GeneralSection() { + // Wave 8: telemetry.enabled privacy toggle. hal0.toml [telemetry].enabled + // defaults to false (opt-in anonymous telemetry). Persisted via the generic + // PUT /api/settings {telemetry:{enabled}} — the backend deep-merges, so we + // only send the one bit. The apply-plan registry classes telemetry.enabled + // as "immediate" (re-read on each save, no restart), rendered by ApplyBadge. + const settings = useSettings(); + const update = useSettingsUpdate(); + const applyPlanQuery = useApplyPlan(); + const registry = applyPlanQuery.data?.registry || {}; + const liveTelemetry = settings.data?.telemetry; + + const [telemetry, setTelemetry] = useStateSet(false); + useEffectSet(() => { + if (settings.data) setTelemetry(settings.data.telemetry?.enabled === true); + }, [settings.data]); + + const telemetryDirty = !!settings.data && telemetry !== (liveTelemetry?.enabled === true); + + const onSaveTelemetry = async () => { + try { + await update.mutateAsync({ telemetry: { enabled: telemetry } }); + window.__hal0Toast && window.__hal0Toast(`Telemetry ${telemetry ? "enabled" : "disabled"}`, "ok"); + } catch (e) { + window.__hal0Toast && window.__hal0Toast(`Save failed — ${e?.message || "see logs"}`, "err"); + } + }; + return (

General

- The dashboard is dark-only by design. Theme / density / accent customization is not available in this release. + Privacy and appearance. The dashboard is dark-only by design — theme / density / accent + customization is not available in this release.

+ + setTelemetry(e.target.checked)} + style={{accentColor: "var(--accent)"}} + /> + {telemetry ? "enabled" : "disabled"} + + } + actions={ +
+ + {telemetryDirty && ( + + )} +
+ } + /> dark · locked} />