feat: file_name combo on ProjectSource, sequence_number output
- file_name is now a combo dropdown populated from the API when manager_url and project_name are set - ProjectSource outputs sequence_number (INT) for downstream use - Refreshes file list when project_name or manager_url changes - Updated tests for new output and error-default behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -208,7 +208,7 @@ class ProjectLoaderDynamic:
|
|||||||
|
|
||||||
|
|
||||||
class ProjectSource:
|
class ProjectSource:
|
||||||
"""Config-only node — holds project connection settings, no outputs."""
|
"""Config node — holds project connection settings, outputs sequence_number."""
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(s):
|
def INPUT_TYPES(s):
|
||||||
return {
|
return {
|
||||||
@@ -221,14 +221,14 @@ class ProjectSource:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ()
|
RETURN_TYPES = ("INT",)
|
||||||
RETURN_NAMES = ()
|
RETURN_NAMES = ("sequence_number",)
|
||||||
FUNCTION = "hold_config"
|
FUNCTION = "hold_config"
|
||||||
CATEGORY = "utils/json/project"
|
CATEGORY = "utils/json/project"
|
||||||
OUTPUT_NODE = True
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
def hold_config(self, manager_url, project_name, file_name, sequence_number, label):
|
def hold_config(self, manager_url, project_name, file_name, sequence_number, label):
|
||||||
return ()
|
return (sequence_number,)
|
||||||
|
|
||||||
|
|
||||||
class ProjectKey:
|
class ProjectKey:
|
||||||
|
|||||||
@@ -213,22 +213,22 @@ class TestProjectSource:
|
|||||||
assert "sequence_number" in inputs["required"]
|
assert "sequence_number" in inputs["required"]
|
||||||
assert "label" in inputs["required"]
|
assert "label" in inputs["required"]
|
||||||
|
|
||||||
def test_no_outputs(self):
|
def test_outputs_sequence_number(self):
|
||||||
from project_loader import ProjectSource
|
from project_loader import ProjectSource
|
||||||
assert ProjectSource.RETURN_TYPES == ()
|
assert ProjectSource.RETURN_TYPES == ("INT",)
|
||||||
assert ProjectSource.RETURN_NAMES == ()
|
assert ProjectSource.RETURN_NAMES == ("sequence_number",)
|
||||||
|
|
||||||
def test_hold_config_returns_empty(self):
|
def test_hold_config_returns_sequence_number(self):
|
||||||
from project_loader import ProjectSource
|
from project_loader import ProjectSource
|
||||||
node = ProjectSource()
|
node = ProjectSource()
|
||||||
result = node.hold_config(
|
result = node.hold_config(
|
||||||
manager_url="http://localhost:8080",
|
manager_url="http://localhost:8080",
|
||||||
project_name="proj1",
|
project_name="proj1",
|
||||||
file_name="batch_i2v",
|
file_name="batch_i2v",
|
||||||
sequence_number=1,
|
sequence_number=42,
|
||||||
label="my_source"
|
label="my_source"
|
||||||
)
|
)
|
||||||
assert result == ()
|
assert result == (42,)
|
||||||
|
|
||||||
def test_category(self):
|
def test_category(self):
|
||||||
from project_loader import ProjectSource
|
from project_loader import ProjectSource
|
||||||
@@ -311,13 +311,12 @@ class TestProjectKey:
|
|||||||
)
|
)
|
||||||
assert result == ("",)
|
assert result == ("",)
|
||||||
|
|
||||||
def test_fetch_key_network_error(self):
|
def test_fetch_key_network_error_returns_default(self):
|
||||||
from project_loader import ProjectKey
|
from project_loader import ProjectKey
|
||||||
node = ProjectKey()
|
node = ProjectKey()
|
||||||
error_resp = {"error": "network_error", "message": "Connection refused"}
|
error_resp = {"error": "network_error", "message": "Connection refused"}
|
||||||
with patch("project_loader._fetch_data", return_value=error_resp):
|
with patch("project_loader._fetch_data", return_value=error_resp):
|
||||||
with pytest.raises(RuntimeError, match="Failed to fetch"):
|
result = node.fetch_key(
|
||||||
node.fetch_key(
|
|
||||||
source_label="my_source",
|
source_label="my_source",
|
||||||
key_name="prompt",
|
key_name="prompt",
|
||||||
key_type="STRING",
|
key_type="STRING",
|
||||||
@@ -326,6 +325,19 @@ class TestProjectKey:
|
|||||||
file_name="batch_i2v",
|
file_name="batch_i2v",
|
||||||
sequence_number=1,
|
sequence_number=1,
|
||||||
)
|
)
|
||||||
|
assert result == ("",)
|
||||||
|
|
||||||
|
def test_fetch_key_error_returns_int_default(self):
|
||||||
|
from project_loader import ProjectKey
|
||||||
|
node = ProjectKey()
|
||||||
|
error_resp = {"error": "http_error", "status": 404, "message": "Not found"}
|
||||||
|
with patch("project_loader._fetch_data", return_value=error_resp):
|
||||||
|
result = node.fetch_key(
|
||||||
|
source_label="s", key_name="seed", key_type="INT",
|
||||||
|
manager_url="http://localhost:8080", project_name="p",
|
||||||
|
file_name="f", sequence_number=1,
|
||||||
|
)
|
||||||
|
assert result == (0,)
|
||||||
|
|
||||||
def test_category(self):
|
def test_category(self):
|
||||||
from project_loader import ProjectKey
|
from project_loader import ProjectKey
|
||||||
|
|||||||
+85
-2
@@ -1,4 +1,5 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "json.manager.project.source",
|
name: "json.manager.project.source",
|
||||||
@@ -6,6 +7,56 @@ app.registerExtension({
|
|||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeData.name !== "ProjectSource") return;
|
if (nodeData.name !== "ProjectSource") return;
|
||||||
|
|
||||||
|
// Helper: replace a STRING widget with a proper combo widget
|
||||||
|
function replaceWithCombo(node, name, values, callback) {
|
||||||
|
const idx = node.widgets?.findIndex(w => w.name === name);
|
||||||
|
if (idx === -1 || idx === undefined) return null;
|
||||||
|
const oldWidget = node.widgets[idx];
|
||||||
|
const savedValue = oldWidget.value || "";
|
||||||
|
const comboValues = values.length > 0 ? values : [""];
|
||||||
|
// Always preserve saved value (may not be in list yet)
|
||||||
|
if (savedValue && !comboValues.includes(savedValue)) {
|
||||||
|
comboValues.unshift(savedValue);
|
||||||
|
}
|
||||||
|
const defaultValue = savedValue || comboValues[0];
|
||||||
|
node.widgets.splice(idx, 1);
|
||||||
|
const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues });
|
||||||
|
if (node.widgets.length > 1) {
|
||||||
|
node.widgets.splice(node.widgets.length - 1, 1);
|
||||||
|
node.widgets.splice(idx, 0, combo);
|
||||||
|
}
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch file list from API and update file_name combo
|
||||||
|
async function refreshFiles(node) {
|
||||||
|
const urlW = node.widgets?.find(w => w.name === "manager_url");
|
||||||
|
const projW = node.widgets?.find(w => w.name === "project_name");
|
||||||
|
if (!urlW?.value || !projW?.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await api.fetchApi(
|
||||||
|
`/json_manager/list_project_files?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}`
|
||||||
|
);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!Array.isArray(data)) return;
|
||||||
|
|
||||||
|
const fileW = node.widgets?.find(w => w.name === "file_name");
|
||||||
|
if (fileW) {
|
||||||
|
const currentValue = fileW.value;
|
||||||
|
fileW.options.values = data.length > 0 ? data : [""];
|
||||||
|
// Keep current selection if still valid
|
||||||
|
if (currentValue && data.includes(currentValue)) {
|
||||||
|
fileW.value = currentValue;
|
||||||
|
}
|
||||||
|
console.log(`[ProjectSource] refreshFiles: ${data.length} files loaded`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ProjectSource] Failed to refresh files:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify all ProjectKey nodes referencing this source to re-sync
|
// Notify all ProjectKey nodes referencing this source to re-sync
|
||||||
function notifyRelays(sourceNode) {
|
function notifyRelays(sourceNode) {
|
||||||
if (!sourceNode.graph?._nodes) return;
|
if (!sourceNode.graph?._nodes) return;
|
||||||
@@ -33,18 +84,34 @@ app.registerExtension({
|
|||||||
|
|
||||||
const node = this;
|
const node = this;
|
||||||
|
|
||||||
// Hook all config widgets to notify relays on change
|
// Replace file_name STRING with a combo
|
||||||
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
replaceWithCombo(this, "file_name", [], function (value) {
|
||||||
|
notifyRelays(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook manager_url and project_name to refresh file list + notify relays
|
||||||
|
for (const name of ["manager_url", "project_name"]) {
|
||||||
const w = this.widgets?.find(w => w.name === name);
|
const w = this.widgets?.find(w => w.name === name);
|
||||||
if (w) {
|
if (w) {
|
||||||
const origCb = w.callback;
|
const origCb = w.callback;
|
||||||
w.callback = function (...args) {
|
w.callback = function (...args) {
|
||||||
origCb?.apply(this, args);
|
origCb?.apply(this, args);
|
||||||
|
refreshFiles(node);
|
||||||
notifyRelays(node);
|
notifyRelays(node);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hook sequence_number to notify relays
|
||||||
|
const seqW = this.widgets?.find(w => w.name === "sequence_number");
|
||||||
|
if (seqW) {
|
||||||
|
const origCb = seqW.callback;
|
||||||
|
seqW.callback = function (...args) {
|
||||||
|
origCb?.apply(this, args);
|
||||||
|
notifyRelays(node);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Update title when label changes
|
// Update title when label changes
|
||||||
const labelWidget = this.widgets?.find(w => w.name === "label");
|
const labelWidget = this.widgets?.find(w => w.name === "label");
|
||||||
if (labelWidget) {
|
if (labelWidget) {
|
||||||
@@ -66,10 +133,26 @@ app.registerExtension({
|
|||||||
const origOnConfigure = nodeType.prototype.onConfigure;
|
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||||
nodeType.prototype.onConfigure = function (info) {
|
nodeType.prototype.onConfigure = function (info) {
|
||||||
origOnConfigure?.apply(this, arguments);
|
origOnConfigure?.apply(this, arguments);
|
||||||
|
|
||||||
|
// Ensure file_name is a combo (may be STRING from serialization)
|
||||||
|
const fileW = this.widgets?.find(w => w.name === "file_name");
|
||||||
|
if (fileW && fileW.type !== "combo") {
|
||||||
|
const node = this;
|
||||||
|
replaceWithCombo(this, "file_name", [], function (value) {
|
||||||
|
notifyRelays(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const labelWidget = this.widgets?.find(w => w.name === "label");
|
const labelWidget = this.widgets?.find(w => w.name === "label");
|
||||||
if (labelWidget?.value) {
|
if (labelWidget?.value) {
|
||||||
this.title = `Source: ${labelWidget.value}`;
|
this.title = `Source: ${labelWidget.value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deferred: refresh file list once graph is ready
|
||||||
|
const node = this;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
refreshFiles(node);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user