Files
silveroxides-ComfyUI_Gemini…/web/response_schema_widgets.js
T
silveroxides 8dbc870407 feat: add Gemini structured response support
Add a DOM schema builder and optional response formatting before image Autogrow. Return thoughts separately from final text and retain all outputs in schema-aware caches.
2026-09-11 19:06:22 +02:00

392 lines
16 KiB
JavaScript

import { addField, removeField, restoreState, selectType, serializeState, updateSetting } from "./response_schema_model.js";
import { EDITOR_CSS } from "./response_schema_style.js";
const registered = new WeakSet();
const TYPES = [
["string", "Text", "Write words or sentences."],
["integer", "Integer", "Return a whole number without decimals."],
["number", "Number", "Return a number, with decimals if needed."],
["boolean", "Boolean", "Answer yes or no."],
["object", "Object", "Keep several named fields together."],
["array", "Array", "Return a list of items, all following the same instructions."],
];
const HELP = {
name: "Give this part of the answer a name, such as title or age.",
required: "When checked, the AI must include this field in its answer.",
description: "Describe the information the AI should put here.",
allowed_values: "Allow any text, or provide the answers the AI may choose from.",
choices: "Enter one allowed answer per line. The AI will choose one of them.",
minimum_mode: "Set the smallest number the AI may return, or leave it unrestricted.",
maximum_mode: "Set the largest number the AI may return, or leave it unrestricted.",
minimum: "The AI may return this number or a larger number.",
maximum: "The AI may return this number or a smaller number.",
};
export function editorNodeSize(current, contentHeight, minimumNodeHeight, manual) {
const width = Math.max(320, current[0]);
const height = manual
? Math.max(minimumNodeHeight, current[1])
: minimumNodeHeight + Math.max(0, Math.min(560, contentHeight + 8) - 140);
return [width, Math.ceil(height)];
}
export class SchemaController {
constructor(node, inputName, data, app, document = globalThis.document) {
this.node = node;
this.app = app;
this.document = document;
this.window = document.defaultView;
this.raw = data[1].default;
this.error = null;
this.disposed = false;
this.layoutFrame = 0;
this.manualSize = false;
this.applyingSize = false;
this.didLayout = false;
this.node.properties ??= {};
this.abort = new this.window.AbortController();
this.element = document.createElement("div");
Object.assign(this.element.style, { width: "100%", height: "100%", minWidth: "0", minHeight: "0", boxSizing: "border-box", overflow: "hidden" });
this.shadow = this.element.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = EDITOR_CSS;
this.editor = this.el("div", "editor");
this.editor.setAttribute("aria-label", "Response format editor");
this.content = this.el("div", "content");
this.editor.append(this.content);
this.shadow.append(style, this.editor);
this.restore(this.raw);
this.carrier = node.addDOMWidget(inputName, "gemini_schema_editor", this.element, {
socketless: true,
hideOnZoom: false,
margin: 4,
getMinHeight: () => 140,
getValue: () => this.error ? this.raw : serializeState(this.state),
setValue: value => this.restore(value),
afterResize: () => {
if (this.didLayout && !this.applyingSize) this.manualSize = true;
this.scheduleLayout();
},
});
this.carrier.options.socketless = true;
this.carrier.serializeValue = () => {
if (this.error) throw new Error(this.error);
if (this.disposed) throw new Error("Schema node was removed.");
return serializeState(this.state);
};
const nativeRemove = this.carrier.onRemove;
this.carrier.onRemove = (...args) => {
this.dispose();
nativeRemove?.apply(this.carrier, args);
};
const options = { signal: this.abort.signal };
this.editor.addEventListener("input", event => this.handleEdit(event), options);
this.editor.addEventListener("change", event => this.handleEdit(event), options);
this.editor.addEventListener("click", event => this.handleRemove(event), options);
this.editor.addEventListener("click", event => this.handleCollapse(event), options);
this.editor.addEventListener("keydown", event => {
if (event.target.matches("input,textarea,select,button")) event.stopPropagation();
}, options);
this.editor.addEventListener("pointerdown", event => {
if (event.button !== 1 && event.target.closest("input,textarea,select,button,label")) event.stopPropagation();
}, options);
this.editor.addEventListener("wheel", event => {
if (event.ctrlKey || event.metaKey) return;
const canScroll = event.deltaY < 0
? this.editor.scrollTop > 0
: this.editor.scrollTop + this.editor.clientHeight < this.editor.scrollHeight;
if (canScroll) event.stopPropagation();
}, options);
this.resizeObserver = new this.window.ResizeObserver(() => this.scheduleLayout());
this.resizeObserver.observe(this.element);
this.resizeObserver.observe(this.content);
this.scheduleLayout();
}
el(tag, className, text) {
const element = this.document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
control(tag, id, key, label, tooltip) {
const wrapper = this.el("label", "control");
const caption = this.el("span", "caption", label);
const input = this.el(tag);
input.dataset.entry = id;
input.dataset.setting = key;
input.setAttribute("aria-label", label);
wrapper.title = tooltip;
input.title = tooltip;
wrapper.append(caption, input);
return { wrapper, input };
}
select(id, key, label, choices, value, help) {
const control = this.control("select", id, key, label, help);
for (const [value, text, title] of choices) {
const option = this.el("option", "", text);
option.value = value;
if (title) option.title = title;
control.input.append(option);
}
control.input.value = value;
return control;
}
textControl(id, key, label, entry, multiline = false) {
const control = this.control(multiline ? "textarea" : "input", id, key, label, HELP[key]);
control.input.value = entry[key];
if (multiline) control.input.rows = key === "choices" ? 3 : 2;
else control.input.type = "text";
control.input.placeholder = key === "name" ? "Name" : "";
return control;
}
renderEntry(id, role) {
const entry = this.state.entries[id];
const section = this.el("section", "entry");
section.dataset.entry = id;
section.setAttribute("aria-label", role === "root" ? "Response" : role === "item" ? "List item" : entry.name.trim() || "Unnamed field");
const header = this.el("div", "entry-header");
if (role === "field" && entry.type !== "disabled") {
const name = this.textControl(id, "name", "Name", entry);
name.wrapper.classList.add("name");
name.input.required = true;
header.append(name.wrapper);
} else {
header.append(this.el("strong", "entry-title", role === "root" ? "Response" : role === "item" ? "List item" : entry.name.trim() || "Disabled field"));
}
const choices = role === "field" ? [["disabled", "Disabled", "Leave this field out of the answer."], ...TYPES] : TYPES;
const type = this.select(id, "type", "Type", choices, entry.type,
"Choose whether this part contains text, a number, named fields, or a list of items.");
type.input.dataset.role = role;
header.append(type.wrapper);
if (role === "field" && entry.type !== "disabled") {
const required = this.control("input", id, "required", "Required", HELP.required);
required.input.type = "checkbox";
required.input.checked = entry.required;
required.wrapper.classList.add("check");
required.wrapper.prepend(required.input);
header.append(required.wrapper);
}
if (role === "field") {
const remove = this.el("button", "remove-field", "Remove");
remove.type = "button";
remove.dataset.remove = id;
remove.title = "Delete this field and all fields inside it, including saved settings.";
remove.setAttribute("aria-label", `Remove ${entry.name.trim() || "field"}`);
header.append(remove);
}
section.append(header);
if (entry.type === "disabled") return section;
const collapsed = (this.node.properties.gemini_schema_collapsed ?? []).includes(id);
const toggle = this.el("button", "collapse-entry", collapsed ? "Expand" : "Collapse");
toggle.type = "button";
toggle.dataset.collapse = id;
toggle.title = "Show or hide this section's settings. Its information stays in the answer format.";
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.setAttribute("aria-controls", `schema-entry-${id}`);
header.append(toggle);
const body = this.el("div", "entry-body");
body.id = `schema-entry-${id}`;
body.hidden = collapsed;
const description = this.textControl(id, "description", "Description", entry);
description.wrapper.classList.add("description", "single-line");
body.append(description.wrapper);
if (entry.type === "string") {
const allowed = this.select(id, "allowed_values", "Allowed values",
[["any", "Any text"], ["listed", "From list"]], entry.allowed_values, HELP.allowed_values);
body.append(allowed.wrapper);
if (entry.allowed_values === "listed") {
const choices = this.textControl(id, "choices", "One answer per line", entry, true);
choices.wrapper.classList.add("description");
body.append(choices.wrapper);
}
}
if (entry.type === "number" || entry.type === "integer") {
const bounds = this.el("div", "bounds");
for (const bound of ["minimum", "maximum"]) {
const group = this.el("div", "bound");
const mode = this.select(id, `${bound}_mode`, bound === "minimum" ? "Minimum" : "Maximum",
[["unbounded", "No limit"], ["set", "Set limit"]], entry[`${bound}_mode`], HELP[`${bound}_mode`]);
group.append(mode.wrapper);
if (entry[`${bound}_mode`] === "set") {
const limit = this.control("input", id, bound, "Value", HELP[bound]);
limit.input.type = "number";
limit.input.step = entry.type === "integer" ? "1" : "any";
limit.input.value = entry[bound];
group.append(limit.wrapper);
}
bounds.append(group);
}
body.append(bounds);
}
if (entry.type === "object") {
const children = this.el("div", "children");
children.setAttribute("aria-label", "Fields");
for (const child of entry.fields) children.append(this.renderEntry(child, "field"));
const add = this.select(id, "add", "Add field",
[["disabled", "Choose type…"], ...TYPES], "disabled", "Choose a type to add another named field to this group.");
add.wrapper.classList.add("add-field");
children.append(add.wrapper);
body.append(children);
} else if (entry.type === "array") {
const children = this.el("div", "children");
children.append(this.renderEntry(entry.item, "item"));
body.append(children);
}
section.append(body);
return section;
}
restore(serialized) {
if (this.disposed) return;
this.raw = serialized;
try {
this.state = restoreState(serialized);
this.error = null;
this.render();
} catch (error) {
this.error = error.message;
const message = this.el("div", "error", `${error.message} Recreate nodes from the older prototype.`);
message.setAttribute("role", "alert");
this.content.replaceChildren(message);
}
this.scheduleLayout();
}
render(focus = null) {
const scroll = this.editor.scrollTop;
this.content.replaceChildren(this.renderEntry(this.state.root, "root"));
if (focus) {
const target = [...this.content.querySelectorAll("[data-setting]")].find(element =>
element.dataset.entry === focus.id && element.dataset.setting === focus.key);
target?.focus({ preventScroll: true });
}
this.editor.scrollTop = scroll;
this.scheduleLayout();
}
handleCollapse(event) {
const button = event.target.closest?.("button[data-collapse]");
if (this.disposed || this.error || !button || !this.content.contains(button)) return;
const id = button.dataset.collapse;
const body = this.shadow.getElementById(`schema-entry-${id}`);
if (!body) return;
event.stopPropagation();
this.node.graph?.beforeChange(this.node);
try {
const collapsed = new Set(this.node.properties.gemini_schema_collapsed ?? []);
if (collapsed.has(id)) collapsed.delete(id);
else collapsed.add(id);
this.node.properties.gemini_schema_collapsed = [...collapsed].filter(key => Object.hasOwn(this.state.entries, key));
body.hidden = collapsed.has(id);
button.textContent = body.hidden ? "Expand" : "Collapse";
button.setAttribute("aria-expanded", String(!body.hidden));
this.scheduleLayout();
} finally {
this.node.graph?.afterChange(this.node);
this.app.canvas?.setDirty(true, true);
}
}
handleRemove(event) {
const button = event.target.closest?.("button[data-remove]");
if (this.disposed || this.error || !button || !this.content.contains(button)) return;
event.stopPropagation();
this.node.graph?.beforeChange(this.node);
try {
const parentId = removeField(this.state, button.dataset.remove);
if (this.node.properties.gemini_schema_collapsed) {
this.node.properties.gemini_schema_collapsed = this.node.properties.gemini_schema_collapsed.filter(id => Object.hasOwn(this.state.entries, id));
}
this.render({ id: parentId, key: "add" });
} finally {
this.node.graph?.afterChange(this.node);
this.app.canvas?.setDirty(true, true);
}
}
handleEdit(event) {
if (this.disposed || this.error) return;
const input = event.target;
const { entry: id, setting: key } = input.dataset ?? {};
if (!id || !key || !this.content.contains(input)) return;
const structural = input.tagName === "SELECT" || input.type === "checkbox";
if ((structural && event.type !== "change") || (!structural && event.type !== "input")) return;
const value = input.type === "checkbox" ? input.checked : input.type === "number"
? (input.value === "" ? "" : Number(input.value)) : input.value;
if (key === "add" && value === "disabled") return;
if (key !== "add" && this.state.entries[id][key] === value) return;
this.node.graph?.beforeChange(this.node);
try {
if (key === "add") {
const newId = addField(this.state, id, value);
this.render({ id: newId, key: "name" });
} else if (key === "type") {
selectType(this.state, id, value, input.dataset.role);
this.render({ id, key });
} else {
updateSetting(this.state, id, key, value);
if (key === "allowed_values" || key.endsWith("_mode")) this.render({ id, key });
if (key === "name") input.closest(".entry").setAttribute("aria-label", value.trim() || "Unnamed field");
}
} finally {
this.node.graph?.afterChange(this.node);
this.app.canvas?.setDirty(true, true);
}
}
scheduleLayout() {
if (!this.window || this.layoutFrame || this.disposed) return;
this.layoutFrame = this.window.requestAnimationFrame(() => {
this.layoutFrame = 0;
this.updateLayout();
});
}
updateLayout() {
if (this.disposed || !this.carrier || !this.element.isConnected) return;
const minimum = this.node.computeSize([this.node.size[0], this.node.size[1]])[1];
const next = editorNodeSize(this.node.size, this.content.scrollHeight, minimum, this.manualSize);
if (next[0] !== this.node.size[0] || next[1] !== this.node.size[1]) {
this.applyingSize = true;
try { this.node.setSize(next); }
finally { this.applyingSize = false; }
}
this.didLayout = true;
this.app.canvas?.setDirty(true, true);
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.abort.abort();
this.resizeObserver?.disconnect();
if (this.layoutFrame) this.window.cancelAnimationFrame(this.layoutFrame);
this.layoutFrame = 0;
this.content.replaceChildren();
}
}
export function createWidgetRegistry(app) {
return {
GEMINI_SCHEMA_BUILDER(node, name, data) {
const controller = new SchemaController(node, name, data, app);
return { widget: controller.carrier, minWidth: 320, minHeight: 140 };
},
};
}
export function registerResponseSchema(app) {
if (registered.has(app)) return;
app.registerExtension({
name: "GeminiExpanded.ResponseSchema",
getCustomWidgets() { return createWidgetRegistry(app); },
});
registered.add(app);
}