feat: add input selector (#20)

This commit is contained in:
bojiang
2025-01-02 21:11:29 +08:00
committed by GitHub
parent f27e0eed50
commit f373caae67
2 changed files with 516 additions and 58 deletions
+39 -6
View File
@@ -88,7 +88,10 @@ def _is_file_refered(file_path: Path, workflow_api: dict) -> bool:
used_inputs.add(v)
all_inputs = "\n".join(used_inputs)
file_path = file_path.absolute().relative_to(folder_paths.base_path)
relpath = Path(*file_path.parts[2:])
if file_path.parts[0] == "input":
relpath = Path(*file_path.parts[1:])
else: # models
relpath = Path(*file_path.parts[2:])
return str(relpath) in all_inputs
@@ -217,15 +220,16 @@ async def _write_inputs(path: ZPath, data: dict) -> None:
input_dir = folder_paths.get_input_directory()
used_inputs = set()
for node in data["workflow_api"].values():
for _, v in node["inputs"].items():
if isinstance(v, str):
used_inputs.add(v)
if "files" in data:
selected = "\n".join(set(data.get("files", [])))
else:
selected = None
src_root = Path(input_dir).absolute()
for src in src_root.glob("**/*"):
rel = src.relative_to(src_root)
if selected is not None and str(rel) not in selected:
continue
if src.is_dir():
if isinstance(path, Path):
path.joinpath("input").joinpath(rel).mkdir(parents=True, exist_ok=True)
@@ -472,6 +476,35 @@ async def get_models(request):
return web.json_response({"models": models})
async def _get_inputs(workflow_api):
input_dir = folder_paths.get_input_directory()
inputs = []
for src in Path(input_dir).rglob("*"):
if src.is_file():
rel = src.relative_to(input_dir)
badges = []
checked = False
if _is_file_refered(src, workflow_api):
badges.append({"text": "Referenced"})
checked = True
data = {
"path": str(rel),
"badges": badges,
"checked": checked,
}
inputs.append(data)
return inputs
@PromptServer.instance.routes.post("/bentoml/file/query")
async def get_inputs(request):
data = await request.json()
inputs = await _get_inputs(
workflow_api=data.get("workflow_api"),
)
return web.json_response({"files": inputs})
@PromptServer.instance.routes.post("/bentoml/build")
async def build_bento_api(request):
"""Request body: {
+477 -52
View File
@@ -9,6 +9,48 @@ const spinner = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"
</svg>`
const style = `
.cpack-tree-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #444;
border-radius: 4px;
padding: 5px;
}
.cpack-tree-item {
padding: 3px 0;
}
.cpack-tree-item label {
display: flex;
align-items: center;
gap: 5px;
min-height: 20px;
}
.cpack-tree-children {
margin-left: 12px;
}
.cpack-tree-toggle {
width: 12px;
height: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 0.8em;
min-width: 12px;
}
.cpack-tree-toggle:hover {
color: #fff;
}
.cpack-tree-toggle.empty {
visibility: hidden;
}
.cpack-modal {
position: fixed;
top: 50%;
@@ -84,6 +126,10 @@ const style = `
margin-bottom: 15px;
}
.cpack-form-item:last-child {
margin-bottom: -5px;
}
.cpack-form-item label {
margin-bottom: 5px;
}
@@ -131,10 +177,323 @@ const style = `
}
`
class TreeState {
constructor() {
this.selectedFiles = new Set();
this.subscribers = new Set();
}
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
notify() {
this.subscribers.forEach(callback => callback(this.selectedFiles));
}
toggle(path, checked) {
if (checked) {
this.selectedFiles.add(path);
} else {
this.selectedFiles.delete(path);
}
this.notify();
}
toggleMultiple(paths, checked) {
paths.forEach(path => {
if (checked) {
this.selectedFiles.add(path);
} else {
this.selectedFiles.delete(path);
}
});
this.notify();
}
isSelected(path) {
return this.selectedFiles.has(path);
}
clear() {
this.selectedFiles.clear();
this.notify();
}
getSelectedCount() {
return this.selectedFiles.size;
}
getSelectedFiles() {
return Array.from(this.selectedFiles);
}
}
class FileTreeList {
constructor(container, countId) {
this.container = container;
this.countId = countId;
this.state = new TreeState();
this.init();
}
init() {
this.container.classList.add('cpack-tree-list');
this.state.subscribe(() => this.updateCount());
}
async load() {
try {
const { workflow, output: workflow_api } = await app.graphToPrompt();
const files = await this.getInputFiles(workflow, workflow_api);
// 预先将默认选中的文件添加到选中列表
files.forEach(file => {
const path = file.path || file;
if (file.checked) {
this.state.toggle(path, true);
}
});
this.renderTree(this.buildTree(files));
// 更新所有父目录的状态
this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => {
this.updateFolderState(checkbox);
});
// 更新总数
this.updateCount();
} catch(e) {
this.container.innerHTML = `<div style="color: #ff8383">Failed to load files: ${e.message}</div>`;
}
}
async getInputFiles(workflow, workflow_api) {
const resp = await api.fetchApi("/bentoml/file/query", {
method: "POST",
body: JSON.stringify({ workflow, workflow_api }),
headers: { "Content-Type": "application/json" }
});
const data = await resp.json();
return Array.isArray(data) ? data : (data.files || []);
}
buildTree(files) {
const root = { name: 'root', children: {}, files: [] };
for (const file of files) {
const filePath = file.path || file;
const parts = filePath.split(/[\/\\]/);
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i === parts.length - 1) {
// 这是文件
current.files.push({
name: part,
path: file.path || file,
badges: file.badges || [],
checked: file.checked || false
});
// 对文件按名称排序
current.files.sort((a, b) => a.name.localeCompare(b.name));
} else {
// 这是目录
if (!current.children[part]) {
current.children[part] = {
name: part,
children: {},
files: []
};
}
current = current.children[part];
}
}
}
// 对目录按名称排序
for (const dir in root.children) {
root.children[dir].files.sort((a, b) => a.name.localeCompare(b.name));
}
return root;
}
renderTree(node, level = 0) {
const dirs = Object.values(node.children);
const hasChildren = dirs.length > 0 || node.files.length > 0;
this.container.innerHTML = this.renderNode(node, true);
this.setupEventListeners();
this.updateCount();
}
renderNode(node, isRoot = false) {
const dirs = Object.values(node.children);
const hasChildren = dirs.length > 0 || node.files.length > 0;
let html = '';
if (!isRoot) {
html += `
<div class="cpack-tree-item">
<label>
<span class="cpack-tree-toggle ${hasChildren ? '' : 'empty'}" data-action="toggle">
${hasChildren ? '▶' : '▶'}
</span>
<input type="checkbox" data-action="check-dir" data-path="${node.name}" />
<span>${node.name}/</span>
</label>
</div>
`;
}
if (hasChildren) {
html += `<div class="cpack-tree-children" style="display: ${isRoot ? '' : 'none'}">`;
// 渲染子目录
for (const dir of dirs) {
html += this.renderNode(dir);
}
// 渲染文件
for (const file of node.files) {
html += `
<div class="cpack-tree-item">
<label style="padding-left: 17px">
<input type="checkbox" name="files" value="${file.path}"
${this.state.isSelected(file.path) || file.checked ? 'checked' : ''} />
<span>${file.name}</span>
${file.badges ? file.badges.map(badge =>
`<span style="background: ${badge.color || '#00a67d33'};
color: ${badge.textColor || '#00a67d'};
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8em;
cursor: help;
white-space: nowrap;"
title="${badge.tooltip || ''}">${badge.text}</span>`
).join('') : ''}
</label>
</div>
`;
}
html += '</div>';
}
return html;
}
updateFolderState(folderCheckbox) {
const treeItem = folderCheckbox.closest('.cpack-tree-item');
const children = treeItem.nextElementSibling;
if (!children) return;
const childFiles = children.querySelectorAll("input[name='files']");
const checkedCount = Array.from(childFiles).filter(cb => cb.checked).length;
if (checkedCount === 0) {
folderCheckbox.checked = false;
folderCheckbox.indeterminate = false;
} else if (checkedCount === childFiles.length) {
folderCheckbox.checked = true;
folderCheckbox.indeterminate = false;
} else {
folderCheckbox.checked = false;
folderCheckbox.indeterminate = true;
}
// 递归更新父文件夹状态
const parentFolder = treeItem.parentElement.closest('.cpack-tree-item');
if (parentFolder) {
const parentCheckbox = parentFolder.querySelector("[data-action='check-dir']");
if (parentCheckbox) {
this.updateFolderState(parentCheckbox);
}
}
// 更新选中文件总数
this.updateCount();
}
setupEventListeners() {
// 目录选择功能
this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const treeItem = e.target.closest('.cpack-tree-item');
const children = treeItem.nextElementSibling;
if (children) {
const childFiles = Array.from(children.querySelectorAll("input[name='files']"))
.map(input => input.value);
this.state.toggleMultiple(childFiles, e.target.checked);
// 更新UI
children.querySelectorAll("input[type='checkbox']").forEach(child => {
child.checked = e.target.checked;
if (child.hasAttribute('data-action')) {
child.indeterminate = false;
}
});
}
});
});
// 折叠功能
this.container.querySelectorAll("[data-action='toggle']").forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const treeItem = e.target.closest('.cpack-tree-item');
const children = treeItem.nextElementSibling;
if (children) {
children.style.display = children.style.display === 'none' ? '' : 'none';
e.target.textContent = children.style.display === 'none' ? '▶' : '▼';
}
});
});
// 文件选择功能
this.container.querySelectorAll("input[name='files']").forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
this.state.toggle(e.target.value, e.target.checked);
// 更新父文件夹状态
const parentFolder = checkbox.closest('.cpack-tree-children')
?.previousElementSibling
?.querySelector("[data-action='check-dir']");
if (parentFolder) {
this.updateFolderState(parentFolder);
}
});
});
}
updateCount() {
const countSpan = document.querySelector(`[data-files-count='${this.countId}']`);
if (countSpan) {
countSpan.textContent = this.state.getSelectedCount();
}
}
getSelectedFiles() {
return this.state.getSelectedFiles();
}
}
class ModelList {
constructor(container, countId) {
this.container = container;
this.countId = countId;
this.selectedModels = new Set();
this.init();
}
@@ -254,8 +613,10 @@ class ModelList {
}
getSelectedModels() {
// 只返回用户选中的模型
return Array.from(this.container.querySelectorAll("input[name='models']:checked"))
.map(input => input.value);
}
}
@@ -292,14 +653,7 @@ async function createPackModal() {
<label for="filename">Name</label>
<input type="text" class="cpack-input" name="filename" value="${localStorage.getItem('cpack-bento-name') || 'comfy-pack-pkg'}" />
</div>
<div class="cpack-form-item">
<details>
<summary style="cursor: pointer; margin-bottom: 10px;">Models (<span data-models-count="models-list">0</span> selected)</summary>
<div id="models-list">
${spinner}
</div>
</details>
</div>
<div id="package-options-container"></div>
`;
const buttonContainer = document.createElement("div");
@@ -322,9 +676,11 @@ async function createPackModal() {
const { close } = createModal(modal);
const modelsList = form.querySelector("#models-list");
const modelListComponent = new ModelList(modelsList, "models-list");
modelListComponent.load().then(() => {
const packageOptionsContainer = form.querySelector("#package-options-container");
const packageOptions = new PackageOptions(form, "pack-models-list", "pack-files-list", true);
packageOptionsContainer.innerHTML = packageOptions.getHtml();
packageOptions.init().then(() => {
confirmButton.disabled = false;
});
@@ -333,10 +689,13 @@ async function createPackModal() {
if (filename) {
// Save filename to localStorage
localStorage.setItem('cpack-bento-name', filename);
const selectedData = packageOptions.getSelectedData();
close();
resolve({
filename,
models: Array.from(form.querySelectorAll("input[name='models']:checked")).map(input => input.value)
models: selectedData.models,
files: selectedData.files,
systemPackages: selectedData.systemPackages
});
}
};
@@ -472,7 +831,9 @@ async function packageAction() {
const body = JSON.stringify({
workflow,
workflow_api,
models: result.models
models: result.models,
files: result.files,
system_packages: result.systemPackages
});
downloadModal.updateProgress(60);
@@ -591,28 +952,102 @@ const serveForm = `
</div>
`
class PackageOptions {
constructor(container, modelsListId, filesListId, defaultOpen = true) {
this.container = container;
this.modelsListId = modelsListId;
this.filesListId = filesListId;
this.modelListComponent = null;
this.fileListComponent = null;
this.defaultOpen = defaultOpen;
}
getHtml() {
return `
<div class="cpack-form-item">
<details ${this.defaultOpen ? 'open' : ''}>
<summary style="cursor: pointer; margin-bottom: 10px;">Package Options</summary>
<div style="padding: 10px; background: #1d1d1d; border-radius: 4px;">
<div class="cpack-form-item">
<details>
<summary style="cursor: pointer; margin-bottom: 10px;">Models (<span data-models-count="${this.modelsListId}">0</span> selected)</summary>
<div id="${this.modelsListId}">
${spinner}
</div>
</details>
</div>
<div class="cpack-form-item">
<details>
<summary style="cursor: pointer; margin-bottom: 10px;">Input Files (<span data-files-count="${this.filesListId}">0</span> selected)</summary>
<div id="${this.filesListId}">
${spinner}
</div>
</details>
</div>
<div class="cpack-form-item">
<details>
<summary style="cursor: pointer; margin-bottom: 10px;">System Packages</summary>
<div id="system-packages-array" style="padding: 10px;">
<button class="cpack-btn" id="add-button" style="margin: 5px 0px">Add Package</button>
</div>
</details>
</div>
</div>
</details>
</div>
`;
}
async init() {
const modelsList = this.container.querySelector(`#${this.modelsListId}`);
const filesList = this.container.querySelector(`#${this.filesListId}`);
this.modelListComponent = new ModelList(modelsList, this.modelsListId);
this.fileListComponent = new FileTreeList(filesList, this.filesListId);
const addButton = this.container.querySelector("#add-button");
const systemPackagesArray = this.container.querySelector("#system-packages-array");
addButton.addEventListener("click", (e) => {
e.preventDefault();
const row = document.createElement("div");
row.className = "cpack-input-row";
row.innerHTML = `
<div style="flex: 1"><input type="text" class="cpack-input" name="systemPackages" placeholder="package name in Ubuntu" /></div>
<button class="cpack-btn" style="margin-left: 10px">Remove</button>
`
systemPackagesArray.appendChild(row);
row.querySelector("button").onclick = (e) => {
e.preventDefault();
row.remove();
}
});
await Promise.all([
this.modelListComponent.load(),
this.fileListComponent.load()
]);
}
getSelectedData() {
return {
models: this.modelListComponent.getSelectedModels(),
files: this.fileListComponent.getSelectedFiles(),
systemPackages: Array.from(this.container.querySelectorAll("input[name='systemPackages']"))
.map(input => input.value)
.filter(Boolean)
};
}
}
const buildForm = `
<p style="font-size: 0.85em; color: #888; margin-top: 5px;">
This feature is powered by <a href="https://www.bentoml.com/?from=comfy-pack" target="_blank" style="color: #00a67d;">BentoCloud</a>, a platform for deploying <br>and managing ML services in customizable clusters
</p>
<div class="cpack-form-item required">
<label for="bentoName">Bento Name</label>
<label for="bentoName">Name</label>
<input type="text" class="cpack-input" name="bentoName" placeholder="comfy-pack-app" />
<div class="error-message">Bento name is required</div>
</div>
<div class="cpack-form-item">
<details>
<summary style="cursor: pointer; margin-bottom: 10px;">Models (<span data-models-count="build-models-list">0</span> selected)</summary>
<div id="build-models-list">
${spinner}
</div>
</details>
</div>
<div class="cpack-form-item">
<label for="systemPackages">System Packages</label>
<div id="system-packages-array">
<button class="cpack-btn" id="add-button" style="margin: 5px 0px">Add</button>
</div>
<div class="error-message">Name is required</div>
</div>
<div class="cpack-form-item required">
<label>BentoCloud API</label>
@@ -628,8 +1063,11 @@ const buildForm = `
<p style="font-size: 0.85em; color: #888; margin-top: 5px;">
Get your API Token at <a href="https://cloud.bentoml.com/signup?from=comfy-pack" target="_blank" style="color: #00a67d;">cloud.bentoml.com</a>
</p>
</div>
</div>
</div>
<div id="package-options-container"></div>
`
function createBuildModal() {
@@ -644,23 +1082,6 @@ function createBuildModal() {
const form = document.createElement("form");
form.innerHTML = buildForm;
const addButton = form.querySelector("#add-button");
const systemPackagesArray = form.querySelector("#system-packages-array");
addButton.addEventListener("click", (e) => {
e.preventDefault();
const row = document.createElement("div");
row.className = "cpack-input-row";
row.innerHTML = `
<div style="flex: 1"><input type="text" class="cpack-input" name="systemPackages" placeholder="package name in Ubuntu" /></div>
<button class="cpack-btn" style="margin-left: 10px">Remove</button>
`
systemPackagesArray.appendChild(row);
row.querySelector("button").onclick = (e) => {
e.preventDefault();
row.remove();
}
});
const buttonContainer = document.createElement("div");
buttonContainer.className = "cpack-btn-container";
@@ -688,9 +1109,11 @@ function createBuildModal() {
return new Promise((resolve) => {
form.querySelector("input[name='bentoName']").select();
const modelsList = form.querySelector("#build-models-list");
const modelListComponent = new ModelList(modelsList, "build-models-list");
modelListComponent.load().then(() => {
const packageOptionsContainer = form.querySelector("#package-options-container");
const packageOptions = new PackageOptions(form, "build-models-list", "build-files-list", false);
packageOptionsContainer.innerHTML = packageOptions.getHtml();
packageOptions.init().then(() => {
confirmButton.disabled = false;
});
@@ -729,13 +1152,15 @@ function createBuildModal() {
localStorage.setItem('cpack-api-key', apiKey);
const { workflow, output: workflow_api } = await app.graphToPrompt();
const selectedData = packageOptions.getSelectedData();
const data = {
bento_name: bentoName,
system_packages: Array.from(formData.getAll("systemPackages").filter(Boolean)),
system_packages: selectedData.systemPackages,
push: true,
api_key: apiKey,
endpoint: endpoint,
models: Array.from(form.querySelectorAll("input[name='models']:checked")).map(input => input.value),
models: selectedData.models,
files: selectedData.files,
workflow,
workflow_api
};