diff --git a/python/api/model_groups.py b/python/api/model_groups.py new file mode 100644 index 0000000000..6ad816d3f1 --- /dev/null +++ b/python/api/model_groups.py @@ -0,0 +1,48 @@ +import uuid +from datetime import datetime +from typing import Any + +from python.helpers import settings +from python.helpers.settings import ModelGroupConfig + + +# Fields needed in model group config +MODEL_GROUP_FIELDS = [ + "chat_model_provider", "chat_model_name", "chat_model_api_base", + "util_model_provider", "util_model_name", "util_model_api_base", + "browser_model_provider", "browser_model_name", "browser_model_api_base", + "embed_model_provider", "embed_model_name", "embed_model_api_base", +] + + +def create_model_group_from_current() -> ModelGroupConfig: + """Create model group config from current settings""" + current = settings.get_settings() + return ModelGroupConfig( + id=str(uuid.uuid4()), + name="", + description="", + created_at=datetime.now().isoformat(), + chat_model_provider=current["chat_model_provider"], + chat_model_name=current["chat_model_name"], + chat_model_api_base=current["chat_model_api_base"], + util_model_provider=current["util_model_provider"], + util_model_name=current["util_model_name"], + util_model_api_base=current["util_model_api_base"], + browser_model_provider=current["browser_model_provider"], + browser_model_name=current["browser_model_name"], + browser_model_api_base=current["browser_model_api_base"], + embed_model_provider=current["embed_model_provider"], + embed_model_name=current["embed_model_name"], + embed_model_api_base=current["embed_model_api_base"], + ) + + +def apply_model_group_to_settings(group: ModelGroupConfig) -> None: + """Apply model group config to current settings""" + current = settings.get_settings() + for field in MODEL_GROUP_FIELDS: + if field in group: + current[field] = group[field] + current["active_model_group_id"] = group["id"] + settings.set_settings(current) \ No newline at end of file diff --git a/python/api/model_groups_create.py b/python/api/model_groups_create.py new file mode 100644 index 0000000000..f1be4072e0 --- /dev/null +++ b/python/api/model_groups_create.py @@ -0,0 +1,42 @@ +import uuid +from datetime import datetime +from python.helpers.api import ApiHandler, Request, Response +from python.helpers import settings +from python.helpers.settings import ModelGroupConfig + + +class ModelGroupsCreate(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + name = input.get("name", "").strip() + description = input.get("description", "").strip() + + if not name: + return {"ok": False, "error": "Name is required"} + + # Create new model group + group = ModelGroupConfig( + id=str(uuid.uuid4()), + name=name, + description=description, + created_at=datetime.now().isoformat(), + chat_model_provider=input.get("chat_model_provider", ""), + chat_model_name=input.get("chat_model_name", ""), + chat_model_api_base=input.get("chat_model_api_base", ""), + util_model_provider=input.get("util_model_provider", ""), + util_model_name=input.get("util_model_name", ""), + util_model_api_base=input.get("util_model_api_base", ""), + browser_model_provider=input.get("browser_model_provider", ""), + browser_model_name=input.get("browser_model_name", ""), + browser_model_api_base=input.get("browser_model_api_base", ""), + embed_model_provider=input.get("embed_model_provider", ""), + embed_model_name=input.get("embed_model_name", ""), + embed_model_api_base=input.get("embed_model_api_base", ""), + ) + + current = settings.get_settings() + groups = current.get("model_groups", []) + groups.append(group) + current["model_groups"] = groups + settings.set_settings(current, apply=False) + + return {"ok": True, "group": group} \ No newline at end of file diff --git a/python/api/model_groups_delete.py b/python/api/model_groups_delete.py new file mode 100644 index 0000000000..b96daca116 --- /dev/null +++ b/python/api/model_groups_delete.py @@ -0,0 +1,27 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers import settings + + +class ModelGroupsDelete(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + group_id = input.get("group_id") + if not group_id: + return {"ok": False, "error": "Missing group_id"} + + current = settings.get_settings() + groups = current.get("model_groups", []) + + new_groups = [g for g in groups if g["id"] != group_id] + + if len(new_groups) == len(groups): + return {"ok": False, "error": "Model group not found"} + + current["model_groups"] = new_groups + + # If deleted group was active, clear active state + if current.get("active_model_group_id") == group_id: + current["active_model_group_id"] = "" + + settings.set_settings(current, apply=False) + + return {"ok": True, "message": "Model group deleted"} \ No newline at end of file diff --git a/python/api/model_groups_duplicate.py b/python/api/model_groups_duplicate.py new file mode 100644 index 0000000000..1e5a3fb883 --- /dev/null +++ b/python/api/model_groups_duplicate.py @@ -0,0 +1,36 @@ +import uuid +import copy +from datetime import datetime +from python.helpers.api import ApiHandler, Request, Response +from python.helpers import settings + + +class ModelGroupsDuplicate(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + group_id = input.get("group_id") + if not group_id: + return {"ok": False, "error": "Missing group_id"} + + current = settings.get_settings() + groups = current.get("model_groups", []) + + source_group = None + for group in groups: + if group["id"] == group_id: + source_group = group + break + + if not source_group: + return {"ok": False, "error": "Model group not found"} + + # Create copy + new_group = copy.deepcopy(source_group) + new_group["id"] = str(uuid.uuid4()) + new_group["name"] = f"{source_group['name']} (Copy)" + new_group["created_at"] = datetime.now().isoformat() + + groups.append(new_group) + current["model_groups"] = groups + settings.set_settings(current, apply=False) + + return {"ok": True, "group": new_group} \ No newline at end of file diff --git a/python/api/model_groups_list.py b/python/api/model_groups_list.py new file mode 100644 index 0000000000..3d7162cede --- /dev/null +++ b/python/api/model_groups_list.py @@ -0,0 +1,15 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers import settings + + +class ModelGroupsList(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + current = settings.get_settings() + groups = current.get("model_groups", []) + active_group_id = current.get("active_model_group_id", "") + + return { + "ok": True, + "groups": groups, + "active_group_id": active_group_id + } diff --git a/python/api/model_groups_providers.py b/python/api/model_groups_providers.py new file mode 100644 index 0000000000..cc70637721 --- /dev/null +++ b/python/api/model_groups_providers.py @@ -0,0 +1,18 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers.providers import get_providers + + +class ModelGroupsProviders(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + chat_providers = get_providers("chat") + embed_providers = get_providers("embedding") + + return { + "ok": True, + "chat_providers": chat_providers, + "embed_providers": embed_providers, + } + + @classmethod + def get_methods(cls) -> list[str]: + return ["GET", "POST"] \ No newline at end of file diff --git a/python/api/model_groups_save.py b/python/api/model_groups_save.py new file mode 100644 index 0000000000..e95b24ccdb --- /dev/null +++ b/python/api/model_groups_save.py @@ -0,0 +1,26 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers import settings +from python.api.model_groups import create_model_group_from_current + + +class ModelGroupsSave(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + name = input.get("name", "").strip() + description = input.get("description", "").strip() + + if not name: + return {"ok": False, "error": "Name is required"} + + # Create model group from current settings + group = create_model_group_from_current() + group["name"] = name + group["description"] = description + + current = settings.get_settings() + groups = current.get("model_groups", []) + groups.append(group) + current["model_groups"] = groups + current["active_model_group_id"] = group["id"] + settings.set_settings(current, apply=False) + + return {"ok": True, "group": group} \ No newline at end of file diff --git a/python/api/model_groups_switch.py b/python/api/model_groups_switch.py new file mode 100644 index 0000000000..825eab27fa --- /dev/null +++ b/python/api/model_groups_switch.py @@ -0,0 +1,37 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers import settings +from python.api.model_groups import MODEL_GROUP_FIELDS + + +class ModelGroupsSwitch(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + group_id = input.get("group_id") # Can be empty or null, meaning switch to default/manual config + + current = settings.get_settings() + + if not group_id: + # Switch to default config (clear model group activation) + current["active_model_group_id"] = "" + settings.set_settings(current, apply=False) + return {"ok": True, "message": "Switched to default configuration"} + + # Find model group + groups = current.get("model_groups", []) + target_group = None + for group in groups: + if group["id"] == group_id: + target_group = group + break + + if not target_group: + return {"ok": False, "error": "Model group not found"} + + # Apply model group config + for field in MODEL_GROUP_FIELDS: + if field in target_group: + current[field] = target_group[field] + + current["active_model_group_id"] = group_id + settings.set_settings(current) # apply=True triggers model reinitialization + + return {"ok": True, "message": f"Switched to model group: {target_group['name']}"} \ No newline at end of file diff --git a/python/api/model_groups_update.py b/python/api/model_groups_update.py new file mode 100644 index 0000000000..04d531ed4e --- /dev/null +++ b/python/api/model_groups_update.py @@ -0,0 +1,42 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers import settings +from python.api.model_groups import MODEL_GROUP_FIELDS + + +class ModelGroupsUpdate(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + group_id = input.get("group_id") + if not group_id: + return {"ok": False, "error": "Missing group_id"} + + current = settings.get_settings() + groups = current.get("model_groups", []) + active_group_id = current.get("active_model_group_id", "") + + for i, group in enumerate(groups): + if group["id"] == group_id: + # Update fields + if "name" in input: + group["name"] = input["name"].strip() + if "description" in input: + group["description"] = input["description"].strip() + + for field in MODEL_GROUP_FIELDS: + if field in input: + group[field] = input[field] + + groups[i] = group + current["model_groups"] = groups + + # If this is the active group, also update current settings + if group_id == active_group_id: + for field in MODEL_GROUP_FIELDS: + if field in group: + current[field] = group[field] + settings.set_settings(current, apply=True) + else: + settings.set_settings(current, apply=False) + + return {"ok": True, "group": group} + + return {"ok": False, "error": "Model group not found"} \ No newline at end of file diff --git a/python/helpers/settings.py b/python/helpers/settings.py index f09124bd70..928f25be9a 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -49,6 +49,33 @@ def get_default_value(name: str, value: T) -> T: ) return value +class ModelGroupConfig(TypedDict): + """Model group configuration, including provider, name, and api_base for four types of models""" + id: str # Unique ID (UUID) + name: str # Display name + description: str # Description (optional) + created_at: str # Creation time + + # Chat Model + chat_model_provider: str + chat_model_name: str + chat_model_api_base: str + + # Utility Model + util_model_provider: str + util_model_name: str + util_model_api_base: str + + # Browser Model + browser_model_provider: str + browser_model_name: str + browser_model_api_base: str + + # Embedding Model + embed_model_provider: str + embed_model_name: str + embed_model_api_base: str + class Settings(TypedDict): version: str @@ -148,6 +175,10 @@ class Settings(TypedDict): update_check_enabled: bool + # Model groups + model_groups: list[ModelGroupConfig] + active_model_group_id: str + class PartialSettings(Settings, total=False): pass @@ -1568,6 +1599,8 @@ def get_default_settings() -> Settings: secrets="", litellm_global_kwargs=get_default_value("litellm_global_kwargs", {}), update_check_enabled=get_default_value("update_check_enabled", True), + model_groups=get_default_value("model_groups", []), + active_model_group_id=get_default_value("active_model_group_id", ""), ) diff --git a/webui/components/chat/top-section/chat-top.html b/webui/components/chat/top-section/chat-top.html index 179495d6cc..3a5c6db4d5 100644 --- a/webui/components/chat/top-section/chat-top.html +++ b/webui/components/chat/top-section/chat-top.html @@ -6,6 +6,7 @@ @@ -33,11 +34,16 @@ + + + + + \ No newline at end of file diff --git a/webui/components/model-groups/model-group-modal.html b/webui/components/model-groups/model-group-modal.html new file mode 100644 index 0000000000..6e11d6a01d --- /dev/null +++ b/webui/components/model-groups/model-group-modal.html @@ -0,0 +1,826 @@ + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/webui/components/model-groups/model-group-store.js b/webui/components/model-groups/model-group-store.js new file mode 100644 index 0000000000..622e177e24 --- /dev/null +++ b/webui/components/model-groups/model-group-store.js @@ -0,0 +1,468 @@ +import { createStore } from "/js/AlpineStore.js"; + +const model = { + // State + groups: [], // All model groups + activeGroupId: "", // Currently active model group ID + dropdownOpen: false, // Dropdown menu open state + managerOpen: false, // Manager modal open state + editorOpen: false, // Editor modal open state + editingGroup: null, // Currently editing model group + isCreating: false, // Whether creating a new model group + loading: false, // Loading state + saveDialogOpen: false, // Save dialog open state + saveDialogName: "", // Save dialog name input + saveDialogDesc: "", // Save dialog description input + + // Provider lists + chatProviders: [], + embedProviders: [], + + // Computed properties + get activeGroupName() { + if (!this.activeGroupId) return null; + const group = this.groups.find(g => g.id === this.activeGroupId); + return group?.name || null; + }, + + get activeGroup() { + if (!this.activeGroupId) return null; + return this.groups.find(g => g.id === this.activeGroupId) || null; + }, + + // Get current chat model provider + get currentChatProvider() { + const group = this.activeGroup; + return group?.chat_model_provider || ""; + }, + + // Get provider icon (SVG) based on chat model provider + getProviderIcon(provider = null) { + const providerName = (provider || this.currentChatProvider || "").toLowerCase(); + + const icons = { + 'openai': '', + 'github': '', + 'anthropic': '', + 'google': '', + 'mistral': '', + 'ollama': '', + 'groq': '', + 'openrouter': '', + 'azure': '', + }; + + // Check for provider match + for (const [key, icon] of Object.entries(icons)) { + if (providerName.includes(key)) return icon; + } + + // Default icon (grid view) + return ''; + }, + + // Get provider icon for a specific group + getProviderIconForGroup(group) { + return this.getProviderIcon(group?.chat_model_provider || ""); + }, + + // Initialize - wait for backend connection before loading + async init() { + // Try initial load + await this.loadGroups(); + await this.loadProviders(); + + // If groups not loaded and backend might not be connected yet, retry after delay + if (this.groups.length === 0) { + setTimeout(async () => { + await this.loadGroups(); + await this.loadProviders(); + }, 1500); + } + }, + + // Load model groups list + async loadGroups() { + try { + const response = await globalThis.sendJsonData("/model_groups_list", {}); + if (response.ok) { + this.groups = response.groups || []; + this.activeGroupId = response.active_group_id || ""; + } + } catch (e) { + console.error("Error loading model groups:", e); + } + }, + + // Load providers list + async loadProviders() { + try { + const response = await globalThis.sendJsonData("/model_groups_providers", {}); + if (response.ok) { + this.chatProviders = response.chat_providers || []; + this.embedProviders = response.embed_providers || []; + } + } catch (e) { + console.error("Error loading providers:", e); + } + }, + + // Toggle dropdown + toggleDropdown() { + this.dropdownOpen = !this.dropdownOpen; + }, + + closeDropdown() { + this.dropdownOpen = false; + }, + + // Switch model group + async switchGroup(groupId) { + this.loading = true; + try { + const response = await globalThis.sendJsonData("/model_groups_switch", { + group_id: groupId + }); + + if (response.ok) { + this.activeGroupId = groupId || ""; + + // Get group name for notification + let groupName = 'Default'; + if (groupId) { + const group = this.groups.find(g => g.id === groupId); + groupName = group?.name || 'Unknown'; + } + + this.closeDropdown(); + + // Show notification with group name + if (globalThis.Alpine?.store && globalThis.Alpine.store('notificationStore')) { + const notifStore = globalThis.Alpine.store('notificationStore'); + notifStore.frontendSuccess( + `Switched to "${groupName}"`, + 'Model Group', + 3, + '', + 10 + ); + } + } else { + throw new Error(response.error || "Failed to switch model group"); + } + } catch (e) { + console.error("Error switching model group:", e); + if (globalThis.toastFetchError) { + globalThis.toastFetchError("Error switching model group", e); + } + } finally { + this.loading = false; + } + }, + + // Open save dialog + openSaveDialog() { + this.saveDialogName = ""; + this.saveDialogDesc = ""; + this.saveDialogOpen = true; + this.closeDropdown(); + }, + + // Close save dialog + closeSaveDialog() { + this.saveDialogOpen = false; + this.saveDialogName = ""; + this.saveDialogDesc = ""; + }, + + // Save current config as new model group + async saveCurrentAsNew() { + this.openSaveDialog(); + }, + + // Confirm save dialog + async confirmSave() { + const name = this.saveDialogName?.trim(); + if (!name) return; + + this.loading = true; + try { + const response = await globalThis.sendJsonData("/model_groups_save", { + name: name, + description: this.saveDialogDesc?.trim() || "" + }); + + if (response.ok) { + await this.loadGroups(); + this.closeSaveDialog(); + + const notifStore = globalThis.Alpine?.store('notificationStore'); + if (notifStore) { + notifStore.frontendSuccess( + "Current configuration saved as new model group", + "Model Group", + 2 + ); + } + } else { + throw new Error(response.error || "Failed to save model group"); + } + } catch (e) { + console.error("Error saving model group:", e); + if (globalThis.toastFetchError) { + globalThis.toastFetchError("Error saving model group", e); + } + } finally { + this.loading = false; + } + }, + + // Open manager modal + openManager() { + this.managerOpen = true; + this.closeDropdown(); + }, + + closeManager() { + this.managerOpen = false; + }, + + // Create new model group + createNew() { + const store = globalThis.Alpine?.store('modelGroups'); + if (store) { + store.isCreating = true; + store.editingGroup = { + name: "", + description: "", + chat_model_provider: "", + chat_model_name: "", + chat_model_api_base: "", + util_model_provider: "", + util_model_name: "", + util_model_api_base: "", + browser_model_provider: "", + browser_model_name: "", + browser_model_api_base: "", + embed_model_provider: "huggingface", + embed_model_name: "sentence-transformers/all-MiniLM-L6-v2", + embed_model_api_base: "", + }; + store.editorOpen = true; + } + }, + + // Edit model group + edit(groupId) { + const store = globalThis.Alpine?.store('modelGroups'); + const group = this.groups.find(g => g.id === groupId); + if (!group || !store) return; + + store.isCreating = false; + + // Set editingGroup first with deep copy + store.editingGroup = JSON.parse(JSON.stringify(group)); + + // Then open editor after data is set, with double nextTick to ensure DOM and Alpine bindings are ready + if (globalThis.Alpine) { + globalThis.Alpine.nextTick(() => { + globalThis.Alpine.nextTick(() => { + store.editorOpen = true; + }); + }); + } else { + store.editorOpen = true; + } + }, + + closeEditor() { + const store = globalThis.Alpine?.store('modelGroups'); + if (store) { + store.editorOpen = false; + store.editingGroup = null; + store.isCreating = false; + } + }, + + // Save edit + async saveEdit() { + if (!this.editingGroup) return; + + const name = this.editingGroup.name?.trim(); + if (!name) { + alert("Name is required"); + return; + } + + this.loading = true; + try { + let response; + if (this.isCreating) { + response = await globalThis.sendJsonData("/model_groups_create", this.editingGroup); + } else { + response = await globalThis.sendJsonData("/model_groups_update", { + group_id: this.editingGroup.id, + ...this.editingGroup + }); + } + + if (response.ok) { + await this.loadGroups(); + this.closeEditor(); + + const notifStore = globalThis.Alpine?.store('notificationStore'); + if (notifStore) { + notifStore.frontendSuccess( + this.isCreating ? "Model group created" : "Model group updated", + "Model Group", + 2 + ); + } + } else { + throw new Error(response.error || "Failed to save model group"); + } + } catch (e) { + console.error("Error saving model group:", e); + if (globalThis.toastFetchError) { + globalThis.toastFetchError("Error saving model group", e); + } + } finally { + this.loading = false; + } + }, + + // Duplicate model group + async duplicate(groupId) { + this.loading = true; + try { + const response = await globalThis.sendJsonData("/model_groups_duplicate", { + group_id: groupId + }); + + if (response.ok) { + await this.loadGroups(); + + const notifStore = globalThis.Alpine?.store('notificationStore'); + if (notifStore) { + notifStore.frontendSuccess( + "Model group duplicated", + "Model Group", + 2 + ); + } + } else { + throw new Error(response.error || "Failed to duplicate model group"); + } + } catch (e) { + console.error("Error duplicating model group:", e); + if (globalThis.toastFetchError) { + globalThis.toastFetchError("Error duplicating model group", e); + } + } finally { + this.loading = false; + } + }, + + // Delete model group + async deleteGroup(groupId) { + const group = this.groups.find(g => g.id === groupId); + if (!group) return; + + this.loading = true; + try { + const response = await globalThis.sendJsonData("/model_groups_delete", { + group_id: groupId + }); + + if (response.ok) { + await this.loadGroups(); + + const notifStore = globalThis.Alpine?.store('notificationStore'); + if (notifStore) { + notifStore.frontendSuccess( + "Model group deleted", + "Model Group", + 2 + ); + } + } else { + throw new Error(response.error || "Failed to delete model group"); + } + } catch (e) { + console.error("Error deleting model group:", e); + if (globalThis.toastFetchError) { + globalThis.toastFetchError("Error deleting model group", e); + } + } finally { + this.loading = false; + } + }, + + // Export model group + async exportGroup(groupId) { + const group = this.groups.find(g => g.id === groupId); + if (!group) return; + + // Create export data (remove id) + const exportData = { ...group }; + delete exportData.id; + delete exportData.created_at; + + // Download as JSON file + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `model-group-${group.name.replace(/[^a-z0-9]/gi, "_")}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + + // Import model group + async importGroup() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + this.loading = true; + const response = await globalThis.sendJsonData("/model_groups_create", data); + + if (response.ok) { + await this.loadGroups(); + + const notifStore = globalThis.Alpine?.store('notificationStore'); + if (notifStore) { + notifStore.frontendSuccess( + "Model group imported", + "Model Group", + 2 + ); + } + } else { + throw new Error(response.error || "Failed to import model group"); + } + } catch (e) { + console.error("Error importing model group:", e); + if (globalThis.toastFetchError) { + globalThis.toastFetchError("Error importing model group", e); + } + } finally { + this.loading = false; + } + }; + + input.click(); + } +}; + +export const store = createStore("modelGroups", model); \ No newline at end of file diff --git a/webui/components/model-groups/model-group-switcher.html b/webui/components/model-groups/model-group-switcher.html new file mode 100644 index 0000000000..e63db3e7ec --- /dev/null +++ b/webui/components/model-groups/model-group-switcher.html @@ -0,0 +1,576 @@ + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/webui/js/confirmClick.js b/webui/js/confirmClick.js index ca5ca87fcc..3a646d31a6 100644 --- a/webui/js/confirmClick.js +++ b/webui/js/confirmClick.js @@ -22,7 +22,13 @@ export function confirmClick(event, action) { action(); } else { const iconEl = button.querySelector('.material-symbols-outlined, .material-icons-outlined'); - const isIconButton = iconEl && button.textContent.trim() === iconEl.textContent.trim(); + // Check if only icon is visible (no visible text children) + const textSpans = button.querySelectorAll('span:not(.material-symbols-outlined):not(.material-icons-outlined)'); + const hasVisibleText = Array.from(textSpans).some(span => { + const style = window.getComputedStyle(span); + return style.display !== 'none' && span.textContent.trim() !== ''; + }); + const isIconButton = iconEl && !hasVisibleText; const newState = { confirming: true,