From f373caae67fc566ddfab4df047b45f7ac9aa65ce Mon Sep 17 00:00:00 2001 From: bojiang <5886138+bojiang@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:11:29 +0800 Subject: [PATCH] feat: add input selector (#20) --- nodes/api.py | 45 ++++- web/main.js | 529 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 516 insertions(+), 58 deletions(-) diff --git a/nodes/api.py b/nodes/api.py index 5676910..2734aec 100644 --- a/nodes/api.py +++ b/nodes/api.py @@ -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: { diff --git a/web/main.js b/web/main.js index 9f2755b..1c8482d 100644 --- a/web/main.js +++ b/web/main.js @@ -9,6 +9,48 @@ const spinner = `` 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 = `
Failed to load files: ${e.message}
`; + } + } + + 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 += ` +
+ +
+ `; + } + + if (hasChildren) { + html += `
`; + + // 渲染子目录 + for (const dir of dirs) { + html += this.renderNode(dir); + } + + // 渲染文件 + for (const file of node.files) { + html += ` +
+ +
+ `; + } + + html += '
'; + } + + 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() { -
-
- Models (0 selected) -
- ${spinner} -
-
-
+
`; 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 = ` ` +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 ` +
+
+ Package Options +
+
+
+ Models (0 selected) +
+ ${spinner} +
+
+
+
+
+ Input Files (0 selected) +
+ ${spinner} +
+
+
+
+
+ System Packages +
+ +
+
+
+
+
+
+ `; + } + + 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 = ` +
+ + ` + 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 = `

This feature is powered by BentoCloud, a platform for deploying
and managing ML services in customizable clusters

- + -
Bento name is required
-
-
-
- Models (0 selected) -
- ${spinner} -
-
-
-
- -
- -
+
Name is required
@@ -628,8 +1063,11 @@ const buildForm = `

Get your API Token at cloud.bentoml.com

+ +
+ - +
` 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 = ` -
- - ` - 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 };