Deleting a refinement key was non-atomic. A key's state spans <key>.json plus sidecars (<key>.value_fn.pt, <key>.sampler_ctx.json, attn_maps/blessed maps + K/V banks + creativity latent + velocity store), and there was no delete route — users removed the visible <key>.json by hand, orphaning the rest. The orphans are still applied: _v2_ascend_conditioning loads <key>.value_fn.pt purely by name (not gated on the json), and blessed K/V inject at a 0.04 floor even at reward 0. So a "cleared" key kept steering future generations, surviving both a ComfyUI restart and the manual delete — the persistent half of the long-run quality drift (the per-session half was the hook leak in the previous commit). - templates.py: delete_refinement_key() sweeps the canonical json + every <key>.* sidecar + clear_refinement_data (attn_maps banks, velocity disk+memory). absolute_store_info()/clear_absolute_store() for the keyless Absolute taste store (__funpack_absolute_global__): it learns from every rated gen across all prompts, is invisible in the key list, and was otherwise only wiped by Session Reset. Routes: POST refinement_keys/delete, GET/POST refinement_keys/absolute|clear_absolute. - Refinement Key Loader node: "Delete Key" + "Clear Global Taste" buttons. - Movie Editor: api deleteRefinementKey/absoluteStoreInfo/clearAbsoluteStore + FunPack menu "Delete refinement key…" / "Clear global taste store…". - tests: atomic-delete sweep + empty-key guard (real store, unique key, cleanup). Pre-existing 16 failures unchanged (stale mocks); +2 new passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
367 lines
12 KiB
JavaScript
367 lines
12 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
const NODE_NAME = "FunPackRefinementKeyLoader";
|
|
const NONE_KEY = "-None-";
|
|
const BUTTON_NAMES = new Set([
|
|
"funpack_refinement_key_export",
|
|
"funpack_refinement_key_import",
|
|
"funpack_refinement_key_refresh",
|
|
"funpack_refinement_key_delete",
|
|
"funpack_refinement_key_clear_absolute",
|
|
]);
|
|
|
|
let cachedKeys = [NONE_KEY];
|
|
const trackedNodes = new Set();
|
|
let pendingRefresh = null;
|
|
|
|
function widgetByName(node, name) {
|
|
return (node.widgets || []).find((widget) => widget.name === name);
|
|
}
|
|
|
|
function keyWidget(node) {
|
|
return widgetByName(node, "refinement_key");
|
|
}
|
|
|
|
function keyNameWidget(node) {
|
|
return widgetByName(node, "key_name");
|
|
}
|
|
|
|
function selectedKey(node) {
|
|
const selected = String(keyWidget(node)?.value || "").trim();
|
|
if (selected && selected !== NONE_KEY) {
|
|
return selected;
|
|
}
|
|
return String(keyNameWidget(node)?.value || "").trim();
|
|
}
|
|
|
|
function fitString(ctx, text, maxWidth) {
|
|
text = String(text ?? "");
|
|
if (ctx.measureText(text).width <= maxWidth) {
|
|
return text;
|
|
}
|
|
const ellipsis = "...";
|
|
let low = 0;
|
|
let high = text.length;
|
|
while (low < high) {
|
|
const mid = Math.ceil((low + high) / 2);
|
|
if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) {
|
|
low = mid;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
return text.slice(0, low) + ellipsis;
|
|
}
|
|
|
|
function updateKeyWidget(node, values = cachedKeys) {
|
|
const widget = keyWidget(node);
|
|
if (!widget) {
|
|
return;
|
|
}
|
|
const current = widget.value;
|
|
const nextValues = current && !values.includes(current) ? [...values, current] : values;
|
|
if (widget.options) {
|
|
widget.options.values = nextValues;
|
|
}
|
|
if (!widget.value || !nextValues.includes(widget.value)) {
|
|
widget.value = nextValues[0] || NONE_KEY;
|
|
}
|
|
}
|
|
|
|
async function fetchKeys() {
|
|
try {
|
|
const response = await api.fetchApi(`/funpack/refinement_keys?cache_bust=${Date.now()}`, {
|
|
cache: "no-store",
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
const values = Array.isArray(data.keys) && data.keys.length ? data.keys : [NONE_KEY];
|
|
cachedKeys = values;
|
|
return values;
|
|
} catch (error) {
|
|
console.warn("FunPack: failed to refresh refinement keys", error);
|
|
return cachedKeys;
|
|
}
|
|
}
|
|
|
|
async function refreshNode(node) {
|
|
const values = await fetchKeys();
|
|
updateKeyWidget(node, values);
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
async function refreshTrackedNodes() {
|
|
if (pendingRefresh) {
|
|
return pendingRefresh;
|
|
}
|
|
pendingRefresh = (async () => {
|
|
const values = await fetchKeys();
|
|
for (const node of [...trackedNodes]) {
|
|
if (!node?.graph) {
|
|
trackedNodes.delete(node);
|
|
continue;
|
|
}
|
|
updateKeyWidget(node, values);
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
})().finally(() => {
|
|
pendingRefresh = null;
|
|
});
|
|
return pendingRefresh;
|
|
}
|
|
|
|
async function exportKey(node) {
|
|
const key = selectedKey(node);
|
|
if (!key) {
|
|
app.canvas?.prompt?.("Refinement key export skipped", "Select or type a refinement key first.", () => {});
|
|
return;
|
|
}
|
|
const response = await api.fetchApi(`/funpack/refinement_keys/export?key=${encodeURIComponent(key)}&cache_bust=${Date.now()}`, {
|
|
cache: "no-store",
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.error || `Export failed with HTTP ${response.status}`);
|
|
}
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${key}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Upload a refinement key. Streams large keys in chunks so reverse proxies on
|
|
// Vast.ai / Runpod can't reject them with HTTP 413, and surfaces a 409 name
|
|
// collision as a tagged error (err.exists / err.key) so the caller can confirm
|
|
// an overwrite and retry. Mirrors movie_editor/frontend/api.js.
|
|
async function importRefinementKeyData(data, overwrite) {
|
|
const ow = overwrite ? "&overwrite=true" : "";
|
|
const bytes = new TextEncoder().encode(JSON.stringify(data));
|
|
const CHUNK = 256 * 1024;
|
|
const finish = async (res) => {
|
|
if (res.status === 409) {
|
|
const p = await res.json().catch(() => ({}));
|
|
const err = new Error(p.error || "Refinement key already exists.");
|
|
err.exists = true; err.key = p.key || "";
|
|
throw err;
|
|
}
|
|
if (!res.ok) {
|
|
const p = await res.json().catch(() => ({}));
|
|
throw new Error(p.error || `Import failed with HTTP ${res.status}`);
|
|
}
|
|
return res.json().catch(() => ({}));
|
|
};
|
|
if (bytes.length <= CHUNK) {
|
|
const res = await api.fetchApi(`/funpack/refinement_keys/import?_=${Date.now()}${ow}`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (res.status !== 413) return finish(res);
|
|
}
|
|
const uploadId = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const postChunk = async (path, body) => {
|
|
const res = await api.fetchApi(path, {
|
|
method: "POST", headers: { "Content-Type": "application/octet-stream" }, body,
|
|
});
|
|
if (!res.ok) {
|
|
const p = await res.json().catch(() => ({}));
|
|
throw new Error(p.error || `Upload failed with HTTP ${res.status}`);
|
|
}
|
|
};
|
|
let index = 0;
|
|
for (let off = 0; off < bytes.length; off += CHUNK, index++) {
|
|
await postChunk(
|
|
`/funpack/refinement_keys/import_chunk?upload_id=${encodeURIComponent(uploadId)}&index=${index}`,
|
|
bytes.slice(off, off + CHUNK),
|
|
);
|
|
}
|
|
return finish(await api.fetchApi(
|
|
`/funpack/refinement_keys/import_finalize?upload_id=${encodeURIComponent(uploadId)}${ow}`,
|
|
{ method: "POST" },
|
|
));
|
|
}
|
|
|
|
// Atomically delete a refinement key AND all its sidecars (value function, blessed
|
|
// attention maps / K/V banks, creativity latent, velocity store). Deleting only the
|
|
// visible <key>.json by hand orphans those sidecars, which keep steering future runs
|
|
// and survive a restart — the backend sweep fixes that.
|
|
async function deleteKey(node) {
|
|
const key = selectedKey(node);
|
|
if (!key) {
|
|
app.canvas?.prompt?.("Delete refinement key skipped", "Select or type a refinement key first.", () => {});
|
|
return;
|
|
}
|
|
if (!confirm(`Delete refinement key "${key}"?\n\nThis removes its learned state AND all sidecars (value function, blessed attention/K-V banks, creativity latent, velocity memory). This cannot be undone.`)) {
|
|
return;
|
|
}
|
|
try {
|
|
const res = await api.fetchApi(`/funpack/refinement_keys/delete?key=${encodeURIComponent(key)}&_=${Date.now()}`, {
|
|
method: "POST", cache: "no-store",
|
|
});
|
|
if (!res.ok) {
|
|
const p = await res.json().catch(() => ({}));
|
|
throw new Error(p.error || `Delete failed with HTTP ${res.status}`);
|
|
}
|
|
const kw = keyWidget(node);
|
|
if (kw) kw.value = NONE_KEY;
|
|
await refreshTrackedNodes();
|
|
} catch (error) {
|
|
console.warn("FunPack: refinement key delete failed", error);
|
|
app.canvas?.prompt?.("Refinement key delete failed", error.message || String(error), () => {});
|
|
}
|
|
}
|
|
|
|
// Clear the keyless Absolute "global taste" store. It learns from EVERY rated
|
|
// generation across all prompts and is invisible in the key list (dunder name), so it
|
|
// can silently bias output (in absolute/both steer mode) and only Session Reset wipes
|
|
// it otherwise. Surfaced here so it can be inspected + cleared directly.
|
|
async function clearAbsoluteStore() {
|
|
let info = { total_iterations: 0, liked_count: 0, bad_count: 0, exists: false };
|
|
try {
|
|
const r = await api.fetchApi(`/funpack/refinement_keys/absolute?_=${Date.now()}`, { cache: "no-store" });
|
|
if (r.ok) info = await r.json();
|
|
} catch (_) { /* fall through with defaults */ }
|
|
if (!info.exists) {
|
|
app.canvas?.prompt?.("Global taste store", "The Absolute global-taste store is already empty.", () => {});
|
|
return;
|
|
}
|
|
if (!confirm(`Clear the Absolute global-taste store?\n\nIt has pooled ${info.total_iterations} rated generation(s) (${info.liked_count} liked / ${info.bad_count} disliked directions) across all prompts. This is applied only in absolute/both steer mode. This cannot be undone.`)) {
|
|
return;
|
|
}
|
|
try {
|
|
const res = await api.fetchApi(`/funpack/refinement_keys/clear_absolute?_=${Date.now()}`, {
|
|
method: "POST", cache: "no-store",
|
|
});
|
|
if (!res.ok) {
|
|
const p = await res.json().catch(() => ({}));
|
|
throw new Error(p.error || `Clear failed with HTTP ${res.status}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn("FunPack: clear absolute store failed", error);
|
|
app.canvas?.prompt?.("Clear global taste failed", error.message || String(error), () => {});
|
|
}
|
|
}
|
|
|
|
function importKey() {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".json,application/json";
|
|
input.onchange = async () => {
|
|
const file = input.files?.[0];
|
|
if (!file) {
|
|
return;
|
|
}
|
|
try {
|
|
const data = JSON.parse(await file.text());
|
|
try {
|
|
await importRefinementKeyData(data, false);
|
|
} catch (e) {
|
|
if (!e.exists) throw e;
|
|
if (!confirm(`A refinement key named "${e.key}" already exists.\n\nOverwrite it with the imported file?`)) {
|
|
return;
|
|
}
|
|
await importRefinementKeyData(data, true);
|
|
}
|
|
await refreshTrackedNodes();
|
|
} catch (error) {
|
|
console.warn("FunPack: refinement key import failed", error);
|
|
app.canvas?.prompt?.("Refinement key import failed", error.message || String(error), () => {});
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
class FunPackRefinementKeyButton {
|
|
constructor(name, label, callback) {
|
|
this.name = name;
|
|
this.type = "custom";
|
|
this.options = { serialize: false };
|
|
this.value = "";
|
|
this.label = label;
|
|
this.callback = callback;
|
|
}
|
|
|
|
computeSize(width) {
|
|
return [width, LiteGraph.NODE_WIDGET_HEIGHT + 2];
|
|
}
|
|
|
|
draw(ctx, _node, width, y, height) {
|
|
const x = 20;
|
|
const w = width - 40;
|
|
const h = height - 2;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y + 1, w, h, [3]);
|
|
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
|
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(fitString(ctx, this.label, w - 12), width / 2, y + height / 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
mouse(event, _pos, node) {
|
|
if (event.type === "pointerup") {
|
|
this.callback(node);
|
|
return true;
|
|
}
|
|
return event.type === "pointerdown";
|
|
}
|
|
}
|
|
|
|
function removeButtons(node) {
|
|
node.widgets = (node.widgets || []).filter((widget) => !BUTTON_NAMES.has(widget.name));
|
|
}
|
|
|
|
function addButtons(node) {
|
|
removeButtons(node);
|
|
const buttons = [
|
|
new FunPackRefinementKeyButton("funpack_refinement_key_export", "Export", (currentNode) => void exportKey(currentNode)),
|
|
new FunPackRefinementKeyButton("funpack_refinement_key_import", "Import", () => importKey()),
|
|
new FunPackRefinementKeyButton("funpack_refinement_key_refresh", "Refresh", (currentNode) => void refreshNode(currentNode)),
|
|
new FunPackRefinementKeyButton("funpack_refinement_key_delete", "Delete Key", (currentNode) => void deleteKey(currentNode)),
|
|
new FunPackRefinementKeyButton("funpack_refinement_key_clear_absolute", "Clear Global Taste", () => void clearAbsoluteStore()),
|
|
];
|
|
for (const button of buttons) {
|
|
node.addCustomWidget(button);
|
|
}
|
|
}
|
|
|
|
function setupNode(node) {
|
|
trackedNodes.add(node);
|
|
updateKeyWidget(node);
|
|
addButtons(node);
|
|
void refreshNode(node);
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "funpack.refinementKeyLoader",
|
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== NODE_NAME) {
|
|
return;
|
|
}
|
|
|
|
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
originalOnNodeCreated?.apply(this, arguments);
|
|
setupNode(this);
|
|
};
|
|
|
|
const originalConfigure = nodeType.prototype.configure;
|
|
nodeType.prototype.configure = function (info) {
|
|
originalConfigure?.apply(this, arguments);
|
|
setupNode(this, info);
|
|
};
|
|
},
|
|
});
|