From 462631e2d0b323f8e1987b7a0d60659ddba9c36f Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:47:02 +0300 Subject: [PATCH] Added the ability to create and edit tags for LoRA --- Rayko_LoRA Loader.py | 100 ++++++++++++++++ Rayko_Models_Loader.py | 100 ++++++++++++++++ __init__.py | 2 +- package.json | 2 +- pyproject.toml | 2 +- web/rayko_lora_loader_widget.js | 204 +++++++++++++++++++++++++++++--- web/rayko_lora_widget.js | 204 +++++++++++++++++++++++++++++--- 7 files changed, 577 insertions(+), 37 deletions(-) diff --git a/Rayko_LoRA Loader.py b/Rayko_LoRA Loader.py index 1d14c7f..a577c48 100644 --- a/Rayko_LoRA Loader.py +++ b/Rayko_LoRA Loader.py @@ -326,5 +326,105 @@ async def rayko_lora_loader_fetch_civitai_info(request): except aiohttp.ClientError as e: return aiohttp.web.json_response({"error": "network", "message": f"Network error: {str(e)}"}) + except Exception as e: + return aiohttp.web.Response(status=500, text=str(e)) + +@PromptServer.instance.routes.post("/rayko_lora_loader/add_tags") +async def rayko_add_tags(request): + try: + data = await request.json() + lora_name = data.get("name", "") + new_tags = data.get("tags", []) + + if not lora_name: + return aiohttp.web.Response(status=400, text="No name provided") + if not isinstance(new_tags, list) or not new_tags: + return aiohttp.web.json_response({"error": "No tags provided"}) + + lora_relative_path = lora_name.replace("\\", "/") + lora_full_path = folder_paths.get_full_path("loras", lora_relative_path) + + if not lora_full_path or not os.path.exists(lora_full_path): + return aiohttp.web.json_response({"error": "File not found"}) + + file_hash = compute_sha256(lora_full_path) + db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json") + + existing_data = {} + if os.path.exists(db_path): + try: + with open(db_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + except Exception: + pass + + existing_tags = existing_data.get("trained_words", []) + if not isinstance(existing_tags, list): + existing_tags = [] + + combined_tags = existing_tags.copy() + for tag in new_tags: + tag = str(tag).strip() + if tag and tag not in combined_tags: + combined_tags.append(tag) + + save_data = { + "full_name": existing_data.get("name", ""), + "trained_words": combined_tags, + "description": existing_data.get("description", "") + } + + save_to_rayko_db(file_hash, save_data, "manual", overwrite=True) + + return aiohttp.web.json_response({ + "trained_words": combined_tags, + "added": len(combined_tags) - len(existing_tags) + }) + + except Exception as e: + return aiohttp.web.Response(status=500, text=str(e)) + +@PromptServer.instance.routes.post("/rayko_lora_loader/remove_tag") +async def rayko_remove_tag(request): + try: + data = await request.json() + lora_name = data.get("name", "") + tag_to_remove = data.get("tag", "") + + if not lora_name or not tag_to_remove: + return aiohttp.web.Response(status=400, text="Missing parameters") + + lora_relative_path = lora_name.replace("\\", "/") + lora_full_path = folder_paths.get_full_path("loras", lora_relative_path) + + if not lora_full_path or not os.path.exists(lora_full_path): + return aiohttp.web.json_response({"error": "File not found"}) + + file_hash = compute_sha256(lora_full_path) + db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json") + + if not os.path.exists(db_path): + return aiohttp.web.json_response({"error": "No data found"}) + + with open(db_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + + existing_tags = existing_data.get("trained_words", []) + if tag_to_remove in existing_tags: + existing_tags.remove(tag_to_remove) + + save_data = { + "full_name": existing_data.get("name", ""), + "trained_words": existing_tags, + "description": existing_data.get("description", "") + } + + save_to_rayko_db(file_hash, save_data, existing_data.get("source", "manual"), overwrite=True) + + return aiohttp.web.json_response({ + "trained_words": existing_tags, + "removed": tag_to_remove + }) + except Exception as e: return aiohttp.web.Response(status=500, text=str(e)) \ No newline at end of file diff --git a/Rayko_Models_Loader.py b/Rayko_Models_Loader.py index 64592c3..c5157a2 100644 --- a/Rayko_Models_Loader.py +++ b/Rayko_Models_Loader.py @@ -396,5 +396,105 @@ async def rayko_loras_delete_preset(request): os.remove(filepath) return aiohttp.web.Response(status=200, text="OK") return aiohttp.web.Response(status=404, text="Preset not found") + except Exception as e: + return aiohttp.web.Response(status=500, text=str(e)) + +@PromptServer.instance.routes.post("/rayko/add_tags") +async def rayko_add_tags(request): + try: + data = await request.json() + lora_name = data.get("name", "") + new_tags = data.get("tags", []) + + if not lora_name: + return aiohttp.web.Response(status=400, text="No name provided") + if not isinstance(new_tags, list) or not new_tags: + return aiohttp.web.json_response({"error": "No tags provided"}) + + lora_relative_path = lora_name.replace("\\", "/") + lora_full_path = folder_paths.get_full_path("loras", lora_relative_path) + + if not lora_full_path or not os.path.exists(lora_full_path): + return aiohttp.web.json_response({"error": "File not found"}) + + file_hash = compute_sha256(lora_full_path) + db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json") + + existing_data = {} + if os.path.exists(db_path): + try: + with open(db_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + except Exception: + pass + + existing_tags = existing_data.get("trained_words", []) + if not isinstance(existing_tags, list): + existing_tags = [] + + combined_tags = existing_tags.copy() + for tag in new_tags: + tag = str(tag).strip() + if tag and tag not in combined_tags: + combined_tags.append(tag) + + save_data = { + "full_name": existing_data.get("name", ""), + "trained_words": combined_tags, + "description": existing_data.get("description", "") + } + + save_to_rayko_db(file_hash, save_data, "manual", overwrite=True) + + return aiohttp.web.json_response({ + "trained_words": combined_tags, + "added": len(combined_tags) - len(existing_tags) + }) + + except Exception as e: + return aiohttp.web.Response(status=500, text=str(e)) + +@PromptServer.instance.routes.post("/rayko/remove_tag") +async def rayko_remove_tag(request): + try: + data = await request.json() + lora_name = data.get("name", "") + tag_to_remove = data.get("tag", "") + + if not lora_name or not tag_to_remove: + return aiohttp.web.Response(status=400, text="Missing parameters") + + lora_relative_path = lora_name.replace("\\", "/") + lora_full_path = folder_paths.get_full_path("loras", lora_relative_path) + + if not lora_full_path or not os.path.exists(lora_full_path): + return aiohttp.web.json_response({"error": "File not found"}) + + file_hash = compute_sha256(lora_full_path) + db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json") + + if not os.path.exists(db_path): + return aiohttp.web.json_response({"error": "No data found"}) + + with open(db_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + + existing_tags = existing_data.get("trained_words", []) + if tag_to_remove in existing_tags: + existing_tags.remove(tag_to_remove) + + save_data = { + "full_name": existing_data.get("name", ""), + "trained_words": existing_tags, + "description": existing_data.get("description", "") + } + + save_to_rayko_db(file_hash, save_data, existing_data.get("source", "manual"), overwrite=True) + + return aiohttp.web.json_response({ + "trained_words": existing_tags, + "removed": tag_to_remove + }) + except Exception as e: return aiohttp.web.Response(status=500, text=str(e)) \ No newline at end of file diff --git a/__init__.py b/__init__.py index d562c35..9212918 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'} REQUIRED_MODULES: Set[str] = set() PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" -__version__ = "0.31.1" +__version__ = "0.31.2" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index ce49099..55a3eab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.31.1", + "version": "0.31.2", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", diff --git a/pyproject.toml b/pyproject.toml index 1fb0799..c1f772a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI_RaykoStudio" description = "Set of custom nodes for ComfyUI providing additional image processing capabilities" -version = "0.31.1" +version = "0.31.2" license = { file = "LICENSE" } dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"] diff --git a/web/rayko_lora_loader_widget.js b/web/rayko_lora_loader_widget.js index 0ff4b9b..dd6fb4c 100644 --- a/web/rayko_lora_loader_widget.js +++ b/web/rayko_lora_loader_widget.js @@ -1231,7 +1231,54 @@ app.registerExtension({ const handleEsc = (e) => { if (e.key === "Escape") { popup.remove(); document.removeEventListener("keydown", handleEsc); } }; document.addEventListener("keydown", handleEsc); - const updateContent = (data) => { + let currentData = null; + + const showDeleteConfirmation = (tag, callback) => { + const overlay = document.createElement("div"); + overlay.id = "rayko-delete-confirm-overlay"; + overlay.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 100002; display: flex; justify-content: center; align-items: center;`; + + const confirmBox = document.createElement("div"); + confirmBox.style.cssText = `background: #1a1a1a; border: 1px solid #f44336; border-radius: 8px; padding: 20px; max-width: 350px; box-shadow: 0 4px 20px rgba(0,0,0,0.9);`; + + const message = document.createElement("div"); + message.textContent = `Remove tag "${tag}"?`; + message.style.cssText = `color: #fff; font-size: 14px; margin-bottom: 20px; text-align: center;`; + + const btnsDiv = document.createElement("div"); + btnsDiv.style.cssText = `display: flex; gap: 10px;`; + + const yesBtn = document.createElement("button"); + yesBtn.textContent = "Yes"; + yesBtn.style.cssText = `flex: 1; background: #3a1a1a; color: #f44336; border: 1px solid #f44336; border-radius: 4px; padding: 8px; cursor: pointer; font-size: 12px;`; + yesBtn.onmouseenter = () => yesBtn.style.background = "#4a2a2a"; + yesBtn.onmouseleave = () => yesBtn.style.background = "#3a1a1a"; + yesBtn.onclick = () => { + overlay.remove(); + callback(true); + }; + + const noBtn = document.createElement("button"); + noBtn.textContent = "No"; + noBtn.style.cssText = `flex: 1; background: #2a2a2a; color: #ccc; border: 1px solid #555; border-radius: 4px; padding: 8px; cursor: pointer; font-size: 12px;`; + noBtn.onmouseenter = () => noBtn.style.background = "#3a3a3a"; + noBtn.onmouseleave = () => noBtn.style.background = "#2a2a2a"; + noBtn.onclick = () => { + overlay.remove(); + callback(false); + }; + + btnsDiv.appendChild(yesBtn); + btnsDiv.appendChild(noBtn); + confirmBox.appendChild(message); + confirmBox.appendChild(btnsDiv); + overlay.appendChild(confirmBox); + document.body.appendChild(overlay); + + yesBtn.focus(); + }; + + const renderChips = (data) => { content.innerHTML = ""; sourceLabel.textContent = `Source: ${data.source || 'unknown'}`; @@ -1256,16 +1303,53 @@ app.registerExtension({ data.trained_words.forEach(word => { const chip = document.createElement("div"); - chip.textContent = word; - chip.style.cssText = `background: #2a3a2a; color: #4CAF50; padding: 4px 10px; border-radius: 12px; font-size: 11px; border: 1px solid #4CAF50; cursor: pointer; user-select: none;`; + chip.style.cssText = `background: #2a3a2a; color: #4CAF50; padding: 4px 10px; border-radius: 12px; font-size: 11px; border: 1px solid #4CAF50; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; position: relative;`; + + const textSpan = document.createElement("span"); + textSpan.textContent = word; + textSpan.style.cssText = `flex: 1;`; + chip.appendChild(textSpan); + + const deleteBtn = document.createElement("span"); + deleteBtn.textContent = "✕"; + deleteBtn.style.cssText = `font-size: 10px; opacity: 0.5; cursor: pointer; padding: 0 2px; line-height: 1;`; + deleteBtn.onmouseenter = () => { deleteBtn.style.opacity = "1"; deleteBtn.style.color = "#f44336"; }; + deleteBtn.onmouseleave = () => { deleteBtn.style.opacity = "0.5"; deleteBtn.style.color = "#4CAF50"; }; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + showDeleteConfirmation(word, (confirmed) => { + if (confirmed) { + fetch("/rayko/remove_tag", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: loraName, tag: word }) + }) + .then(res => res.json()) + .then(result => { + if (result.error) { + showRaykoToast(result.error, "error", this); + } else { + showRaykoToast(`Tag "${word}" removed`, "success", this); + currentData = { ...currentData, trained_words: result.trained_words }; + renderChips(currentData); + } + }) + .catch(err => showRaykoToast("Failed to remove tag", "error", this)); + } + }); + }; + chip.appendChild(deleteBtn); + chip.onmouseenter = () => { chip.style.background = "#3a5a3a"; chip.style.borderColor = "#66dd66"; }; chip.onmouseleave = () => { chip.style.background = "#2a3a2a"; chip.style.borderColor = "#4CAF50"; }; - chip.onclick = () => { - navigator.clipboard.writeText(word).then(() => { - showRaykoToast(`Copied: "${word}"`, "success", this); - popup.remove(); - document.removeEventListener("keydown", handleEsc); - }).catch(() => showRaykoToast("Failed to copy", "error", this)); + chip.onclick = (e) => { + if (e.target !== deleteBtn && !deleteBtn.contains(e.target)) { + navigator.clipboard.writeText(word).then(() => { + showRaykoToast(`Copied: "${word}"`, "success", this); + popup.remove(); + document.removeEventListener("keydown", handleEsc); + }).catch(() => showRaykoToast("Failed to copy", "error", this)); + } }; twContainer.appendChild(chip); }); @@ -1285,6 +1369,9 @@ app.registerExtension({ }).catch(() => showRaykoToast("Failed to copy", "error", this)); }; content.appendChild(copyBtn); + } else { + content.textContent = "No trained words found"; + content.style.color = "#888"; } if (data.description) { @@ -1299,16 +1386,22 @@ app.registerExtension({ content.appendChild(desc); } - if (!data.trained_words?.length && !data.description) { - content.textContent = "No metadata available"; - content.style.color = "#888"; - } - const actionsDiv = document.createElement("div"); actionsDiv.style.cssText = `display: flex; gap: 8px; margin-top: 15px; flex-wrap: wrap;`; + const addTagsBtn = document.createElement("button"); + const hasTags = data.trained_words && data.trained_words.length > 0; + addTagsBtn.textContent = "➕ Add Tags"; + addTagsBtn.style.cssText = hasTags + ? `flex: 1; background: #2a2a2a; color: #aaa; border: 1px solid #555; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;` + : `flex: 1; background: #3a2a1a; color: #fbbf24; border: 1px solid #fbbf24; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; + addTagsBtn.onmouseenter = () => addTagsBtn.style.background = hasTags ? "#3a3a3a" : "#4a3a2a"; + addTagsBtn.onmouseleave = () => addTagsBtn.style.background = hasTags ? "#2a2a2a" : "#3a2a1a"; + addTagsBtn.onclick = () => showTextarea(); + actionsDiv.appendChild(addTagsBtn); + const fetchBtn = document.createElement("button"); - fetchBtn.textContent = "🌐 Fetch from Civitai"; + fetchBtn.textContent = " Fetch from Civitai"; fetchBtn.style.cssText = `flex: 1; background: #3a2a1a; color: #fbbf24; border: 1px solid #fbbf24; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; fetchBtn.onmouseenter = () => fetchBtn.style.background = "#4a3a2a"; fetchBtn.onmouseleave = () => fetchBtn.style.background = "#3a2a1a"; @@ -1329,7 +1422,8 @@ app.registerExtension({ content.style.color = "#f44336"; sourceLabel.textContent = ""; } else { - updateContent(result); + currentData = result; + renderChips(result); } } catch (err) { content.textContent = "Network error"; @@ -1342,13 +1436,89 @@ app.registerExtension({ content.appendChild(actionsDiv); }; + const showTextarea = () => { + content.innerHTML = ""; + + const textarea = document.createElement("textarea"); + textarea.style.cssText = `width: 100%; height: 120px; background: #111; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 8px; font-size: 12px; font-family: monospace; resize: vertical; box-sizing: border-box;`; + textarea.placeholder = "One tag per line\nEmpty lines will be ignored"; + content.appendChild(textarea); + + const btnsDiv = document.createElement("div"); + btnsDiv.style.cssText = `display: flex; gap: 8px; margin-top: 10px;`; + + const saveBtn = document.createElement("button"); + saveBtn.textContent = "💾 Save"; + saveBtn.style.cssText = `flex: 1; background: #1a3a2a; color: #4CAF50; border: 1px solid #4CAF50; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; + saveBtn.onmouseenter = () => saveBtn.style.background = "#2a4a3a"; + saveBtn.onmouseleave = () => saveBtn.style.background = "#1a3a2a"; + saveBtn.onclick = async () => { + const text = textarea.value; + const tags = text.split('\n').map(t => t.trim()).filter(t => t.length > 0); + + if (tags.length === 0) { + showRaykoToast("No tags entered", "error", this); + return; + } + + saveBtn.textContent = "⏳ Saving..."; + saveBtn.disabled = true; + saveBtn.style.opacity = "0.5"; + + try { + const res = await fetch("/rayko_lora_loader/add_tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: loraName, tags: tags }) + }); + const result = await res.json(); + + if (result.error) { + showRaykoToast(result.error, "error", this); + saveBtn.textContent = " Save"; + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + return; + } + + showRaykoToast(`${result.added} tag(s) added`, "success", this); + currentData = { ...currentData, trained_words: result.trained_words, source: "manual" }; + renderChips(currentData); + } catch (err) { + showRaykoToast("Failed to save tags", "error", this); + saveBtn.textContent = "💾 Save"; + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + } + }; + btnsDiv.appendChild(saveBtn); + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "❌ Cancel"; + cancelBtn.style.cssText = `flex: 1; background: #2a2a2a; color: #ccc; border: 1px solid #555; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; + cancelBtn.onmouseenter = () => cancelBtn.style.background = "#3a3a3a"; + cancelBtn.onmouseleave = () => cancelBtn.style.background = "#2a2a2a"; + cancelBtn.onclick = () => { + if (currentData) { + renderChips(currentData); + } + }; + btnsDiv.appendChild(cancelBtn); + + content.appendChild(btnsDiv); + textarea.focus(); + }; + fetch("/rayko_lora_loader/get_lora_info", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: loraName }) }) .then(res => res.json()) - .then(updateContent) + .then(data => { + currentData = data; + renderChips(data); + }) .catch(err => { content.textContent = "Error loading metadata"; content.style.color = "#f44336"; diff --git a/web/rayko_lora_widget.js b/web/rayko_lora_widget.js index 9fac726..cc0f35b 100644 --- a/web/rayko_lora_widget.js +++ b/web/rayko_lora_widget.js @@ -1249,7 +1249,54 @@ app.registerExtension({ const handleEsc = (e) => { if (e.key === "Escape") { popup.remove(); document.removeEventListener("keydown", handleEsc); } }; document.addEventListener("keydown", handleEsc); - const updateContent = (data) => { + let currentData = null; + + const showDeleteConfirmation = (tag, callback) => { + const overlay = document.createElement("div"); + overlay.id = "rayko-delete-confirm-overlay"; + overlay.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 100002; display: flex; justify-content: center; align-items: center;`; + + const confirmBox = document.createElement("div"); + confirmBox.style.cssText = `background: #1a1a1a; border: 1px solid #f44336; border-radius: 8px; padding: 20px; max-width: 350px; box-shadow: 0 4px 20px rgba(0,0,0,0.9);`; + + const message = document.createElement("div"); + message.textContent = `Remove tag "${tag}"?`; + message.style.cssText = `color: #fff; font-size: 14px; margin-bottom: 20px; text-align: center;`; + + const btnsDiv = document.createElement("div"); + btnsDiv.style.cssText = `display: flex; gap: 10px;`; + + const yesBtn = document.createElement("button"); + yesBtn.textContent = "Yes"; + yesBtn.style.cssText = `flex: 1; background: #3a1a1a; color: #f44336; border: 1px solid #f44336; border-radius: 4px; padding: 8px; cursor: pointer; font-size: 12px;`; + yesBtn.onmouseenter = () => yesBtn.style.background = "#4a2a2a"; + yesBtn.onmouseleave = () => yesBtn.style.background = "#3a1a1a"; + yesBtn.onclick = () => { + overlay.remove(); + callback(true); + }; + + const noBtn = document.createElement("button"); + noBtn.textContent = "No"; + noBtn.style.cssText = `flex: 1; background: #2a2a2a; color: #ccc; border: 1px solid #555; border-radius: 4px; padding: 8px; cursor: pointer; font-size: 12px;`; + noBtn.onmouseenter = () => noBtn.style.background = "#3a3a3a"; + noBtn.onmouseleave = () => noBtn.style.background = "#2a2a2a"; + noBtn.onclick = () => { + overlay.remove(); + callback(false); + }; + + btnsDiv.appendChild(yesBtn); + btnsDiv.appendChild(noBtn); + confirmBox.appendChild(message); + confirmBox.appendChild(btnsDiv); + overlay.appendChild(confirmBox); + document.body.appendChild(overlay); + + yesBtn.focus(); + }; + + const renderChips = (data) => { content.innerHTML = ""; sourceLabel.textContent = `Source: ${data.source || 'unknown'}`; @@ -1274,16 +1321,53 @@ app.registerExtension({ data.trained_words.forEach(word => { const chip = document.createElement("div"); - chip.textContent = word; - chip.style.cssText = `background: #2a3a2a; color: #4CAF50; padding: 4px 10px; border-radius: 12px; font-size: 11px; border: 1px solid #4CAF50; cursor: pointer; user-select: none;`; + chip.style.cssText = `background: #2a3a2a; color: #4CAF50; padding: 4px 10px; border-radius: 12px; font-size: 11px; border: 1px solid #4CAF50; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; position: relative;`; + + const textSpan = document.createElement("span"); + textSpan.textContent = word; + textSpan.style.cssText = `flex: 1;`; + chip.appendChild(textSpan); + + const deleteBtn = document.createElement("span"); + deleteBtn.textContent = "✕"; + deleteBtn.style.cssText = `font-size: 10px; opacity: 0.5; cursor: pointer; padding: 0 2px; line-height: 1;`; + deleteBtn.onmouseenter = () => { deleteBtn.style.opacity = "1"; deleteBtn.style.color = "#f44336"; }; + deleteBtn.onmouseleave = () => { deleteBtn.style.opacity = "0.5"; deleteBtn.style.color = "#4CAF50"; }; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + showDeleteConfirmation(word, (confirmed) => { + if (confirmed) { + fetch("/rayko/remove_tag", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: loraName, tag: word }) + }) + .then(res => res.json()) + .then(result => { + if (result.error) { + showRaykoToast(result.error, "error", this); + } else { + showRaykoToast(`Tag "${word}" removed`, "success", this); + currentData = { ...currentData, trained_words: result.trained_words }; + renderChips(currentData); + } + }) + .catch(err => showRaykoToast("Failed to remove tag", "error", this)); + } + }); + }; + chip.appendChild(deleteBtn); + chip.onmouseenter = () => { chip.style.background = "#3a5a3a"; chip.style.borderColor = "#66dd66"; }; chip.onmouseleave = () => { chip.style.background = "#2a3a2a"; chip.style.borderColor = "#4CAF50"; }; - chip.onclick = () => { - navigator.clipboard.writeText(word).then(() => { - showRaykoToast(`Copied: "${word}"`, "success", this); - popup.remove(); - document.removeEventListener("keydown", handleEsc); - }).catch(() => showRaykoToast("Failed to copy", "error", this)); + chip.onclick = (e) => { + if (e.target !== deleteBtn && !deleteBtn.contains(e.target)) { + navigator.clipboard.writeText(word).then(() => { + showRaykoToast(`Copied: "${word}"`, "success", this); + popup.remove(); + document.removeEventListener("keydown", handleEsc); + }).catch(() => showRaykoToast("Failed to copy", "error", this)); + } }; twContainer.appendChild(chip); }); @@ -1303,6 +1387,9 @@ app.registerExtension({ }).catch(() => showRaykoToast("Failed to copy", "error", this)); }; content.appendChild(copyBtn); + } else { + content.textContent = "No trained words found"; + content.style.color = "#888"; } if (data.description) { @@ -1317,16 +1404,22 @@ app.registerExtension({ content.appendChild(desc); } - if (!data.trained_words?.length && !data.description) { - content.textContent = "No metadata available"; - content.style.color = "#888"; - } - const actionsDiv = document.createElement("div"); actionsDiv.style.cssText = `display: flex; gap: 8px; margin-top: 15px; flex-wrap: wrap;`; + const addTagsBtn = document.createElement("button"); + const hasTags = data.trained_words && data.trained_words.length > 0; + addTagsBtn.textContent = "➕ Add Tags"; + addTagsBtn.style.cssText = hasTags + ? `flex: 1; background: #2a2a2a; color: #aaa; border: 1px solid #555; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;` + : `flex: 1; background: #3a2a1a; color: #fbbf24; border: 1px solid #fbbf24; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; + addTagsBtn.onmouseenter = () => addTagsBtn.style.background = hasTags ? "#3a3a3a" : "#4a3a2a"; + addTagsBtn.onmouseleave = () => addTagsBtn.style.background = hasTags ? "#2a2a2a" : "#3a2a1a"; + addTagsBtn.onclick = () => showTextarea(); + actionsDiv.appendChild(addTagsBtn); + const fetchBtn = document.createElement("button"); - fetchBtn.textContent = "🌐 Fetch from Civitai"; + fetchBtn.textContent = " Fetch from Civitai"; fetchBtn.style.cssText = `flex: 1; background: #3a2a1a; color: #fbbf24; border: 1px solid #fbbf24; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; fetchBtn.onmouseenter = () => fetchBtn.style.background = "#4a3a2a"; fetchBtn.onmouseleave = () => fetchBtn.style.background = "#3a2a1a"; @@ -1347,7 +1440,8 @@ app.registerExtension({ content.style.color = "#f44336"; sourceLabel.textContent = ""; } else { - updateContent(result); + currentData = result; + renderChips(result); } } catch (err) { content.textContent = "Network error"; @@ -1360,13 +1454,89 @@ app.registerExtension({ content.appendChild(actionsDiv); }; + const showTextarea = () => { + content.innerHTML = ""; + + const textarea = document.createElement("textarea"); + textarea.style.cssText = `width: 100%; height: 120px; background: #111; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 8px; font-size: 12px; font-family: monospace; resize: vertical; box-sizing: border-box;`; + textarea.placeholder = "One tag per line\nEmpty lines will be ignored"; + content.appendChild(textarea); + + const btnsDiv = document.createElement("div"); + btnsDiv.style.cssText = `display: flex; gap: 8px; margin-top: 10px;`; + + const saveBtn = document.createElement("button"); + saveBtn.textContent = "💾 Save"; + saveBtn.style.cssText = `flex: 1; background: #1a3a2a; color: #4CAF50; border: 1px solid #4CAF50; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; + saveBtn.onmouseenter = () => saveBtn.style.background = "#2a4a3a"; + saveBtn.onmouseleave = () => saveBtn.style.background = "#1a3a2a"; + saveBtn.onclick = async () => { + const text = textarea.value; + const tags = text.split('\n').map(t => t.trim()).filter(t => t.length > 0); + + if (tags.length === 0) { + showRaykoToast("No tags entered", "error", this); + return; + } + + saveBtn.textContent = "⏳ Saving..."; + saveBtn.disabled = true; + saveBtn.style.opacity = "0.5"; + + try { + const res = await fetch("/rayko/add_tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: loraName, tags: tags }) + }); + const result = await res.json(); + + if (result.error) { + showRaykoToast(result.error, "error", this); + saveBtn.textContent = " Save"; + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + return; + } + + showRaykoToast(`${result.added} tag(s) added`, "success", this); + currentData = { ...currentData, trained_words: result.trained_words, source: "manual" }; + renderChips(currentData); + } catch (err) { + showRaykoToast("Failed to save tags", "error", this); + saveBtn.textContent = "💾 Save"; + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + } + }; + btnsDiv.appendChild(saveBtn); + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "❌ Cancel"; + cancelBtn.style.cssText = `flex: 1; background: #2a2a2a; color: #ccc; border: 1px solid #555; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 11px;`; + cancelBtn.onmouseenter = () => cancelBtn.style.background = "#3a3a3a"; + cancelBtn.onmouseleave = () => cancelBtn.style.background = "#2a2a2a"; + cancelBtn.onclick = () => { + if (currentData) { + renderChips(currentData); + } + }; + btnsDiv.appendChild(cancelBtn); + + content.appendChild(btnsDiv); + textarea.focus(); + }; + fetch("/rayko/get_lora_info", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: loraName }) }) .then(res => res.json()) - .then(updateContent) + .then(data => { + currentData = data; + renderChips(data); + }) .catch(err => { content.textContent = "Error loading metadata"; content.style.color = "#f44336";