feat(hf): add Link to HuggingFace feature with unified Link Model submenu

- Merge Relink to Civitai and new Link to HuggingFace into a single
  'Link Model' submenu with sub-options for each source
- Add POST /api/lm/set-hf-url endpoint to associate a model with a
  HuggingFace repo URL, saving hf_url to .metadata.json
- Add link_hf_modal.html for URL input, following relink-civitai pattern
- Use update_single_model_cache instead of add_model_to_cache to
  prevent duplicate cache entries after linking
- Remove os.path.realpath usage for consistency with relink-civitai
- Raise errors instead of silently falling back to LoRA scanner when
  model root cannot be determined
- Scope .input-group CSS rules to modal IDs to fix style conflicts
  with download-modal.css
- Add i18n keys across all 10 locales with translations for
  zh-CN, zh-TW, ja, ko, de, es, fr, he, ru
This commit is contained in:
Will Miao
2026-07-07 20:04:47 +08:00
parent b019326747
commit 2638109ad6
22 changed files with 399 additions and 17 deletions

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "Civitai-Daten aktualisieren",
"checkUpdates": "Updates prüfen",
"relinkCivitai": "Mit Civitai neu verknüpfen",
"linkModel": "Modell verknüpfen",
"linkCivitai": "Mit Civitai neu verknüpfen",
"linkHuggingFace": "Mit HuggingFace verknüpfen",
"copySyntax": "LoRA-Syntax kopieren",
"copyFilename": "Modell-Dateiname kopieren",
"copyRecipeSyntax": "Rezept-Syntax kopieren",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
"root": "Stammverzeichnis"
},
"linkHuggingFace": {
"title": "Mit HuggingFace verknüpfen",
"infoText": "Fügen Sie die HuggingFace-Repository-URL ein, um dieses Modell zuzuordnen. Dies ermöglicht die KI-gestützte Metadatenanreicherung.",
"urlLabel": "HuggingFace-Repository-URL:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Geben Sie die vollständige URL des HuggingFace-Repositorys ein.",
"confirmAction": "Speichern & Verknüpfen"
},
"relinkCivitai": {
"title": "Mit Civitai neu verknüpfen",
"warning": "Warnung:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "Fehler beim Setzen der Inhaltsbewertung: {message}",
"relinkSuccess": "Modell erfolgreich mit Civitai neu verknüpft",
"relinkFailed": "Fehler: {message}",
"linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft",
"linkHfFailed": "Fehler: {message}",
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
"missingHash": "Modell-Hash nicht verfügbar"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "Refresh Civitai Data",
"checkUpdates": "Check Updates",
"relinkCivitai": "Re-link to Civitai",
"linkModel": "Link Model",
"linkCivitai": "Link to Civitai",
"linkHuggingFace": "Link to HuggingFace",
"copySyntax": "Copy LoRA Syntax",
"copyFilename": "Copy Model Filename",
"copyRecipeSyntax": "Copy Recipe Syntax",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "Type folder path or select from tree below...",
"root": "Root"
},
"linkHuggingFace": {
"title": "Link to HuggingFace",
"infoText": "Paste the HuggingFace repository URL to associate this model with its source. This enables AI-powered metadata enrichment.",
"urlLabel": "HuggingFace Repository URL:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Enter the full URL of the HuggingFace repository.",
"confirmAction": "Save & Link"
},
"relinkCivitai": {
"title": "Re-link to Civitai",
"warning": "Warning:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "Failed to set content rating: {message}",
"relinkSuccess": "Model successfully re-linked to Civitai",
"relinkFailed": "Error: {message}",
"linkHfSuccess": "Model successfully linked to HuggingFace",
"linkHfFailed": "Error: {message}",
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
"noCivitaiInfo": "No CivitAI information available",
"missingHash": "Model hash not available"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "Actualizar datos de Civitai",
"checkUpdates": "Comprobar actualizaciones",
"relinkCivitai": "Re-vincular a Civitai",
"linkModel": "Vincular modelo",
"linkCivitai": "Re-vincular a Civitai",
"linkHuggingFace": "Vincular a HuggingFace",
"copySyntax": "Copiar sintaxis de LoRA",
"copyFilename": "Copiar nombre de archivo del modelo",
"copyRecipeSyntax": "Copiar sintaxis de receta",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
"root": "Raíz"
},
"linkHuggingFace": {
"title": "Vincular a HuggingFace",
"infoText": "Pegue la URL del repositorio de HuggingFace para asociar este modelo. Esto permite el enriquecimiento de metadatos con IA.",
"urlLabel": "URL del repositorio de HuggingFace:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Ingrese la URL completa del repositorio de HuggingFace.",
"confirmAction": "Guardar y vincular"
},
"relinkCivitai": {
"title": "Re-vincular a Civitai",
"warning": "Advertencia:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "Error al establecer clasificación de contenido: {message}",
"relinkSuccess": "Modelo re-vinculado exitosamente a Civitai",
"relinkFailed": "Error: {message}",
"linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente",
"linkHfFailed": "Error: {message}",
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
"noCivitaiInfo": "No hay información de CivitAI disponible",
"missingHash": "Hash del modelo no disponible"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "Actualiser les données Civitai",
"checkUpdates": "Vérifier les mises à jour",
"relinkCivitai": "Relier à nouveau à Civitai",
"linkModel": "Lier le modèle",
"linkCivitai": "Relier à nouveau à Civitai",
"linkHuggingFace": "Lier à HuggingFace",
"copySyntax": "Copier la syntaxe LoRA",
"copyFilename": "Copier le nom de fichier du modèle",
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
"root": "Racine"
},
"linkHuggingFace": {
"title": "Lier à HuggingFace",
"infoText": "Collez l'URL du dépôt HuggingFace pour associer ce modèle à sa source. Cela permet l'enrichissement des métadonnées par IA.",
"urlLabel": "URL du dépôt HuggingFace :",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Entrez l'URL complète du dépôt HuggingFace.",
"confirmAction": "Enregistrer & lier"
},
"relinkCivitai": {
"title": "Relier à nouveau à Civitai",
"warning": "Attention :",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "Échec de la définition de la classification du contenu : {message}",
"relinkSuccess": "Modèle relié à Civitai avec succès",
"relinkFailed": "Erreur : {message}",
"linkHfSuccess": "Modèle lié à HuggingFace avec succès",
"linkHfFailed": "Erreur : {message}",
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
"noCivitaiInfo": "Aucune information CivitAI disponible",
"missingHash": "Hash du modèle non disponible"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "רענן נתוני Civitai",
"checkUpdates": "בדוק עדכונים",
"relinkCivitai": שר מחדש ל-Civitai",
"linkModel": ישור מודל",
"linkCivitai": "קשר מחדש ל-Civitai",
"linkHuggingFace": "קישור ל-HuggingFace",
"copySyntax": "העתק תחביר LoRA",
"copyFilename": "העתק שם קובץ מודל",
"copyRecipeSyntax": "העתק תחביר מתכון",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
"root": "שורש"
},
"linkHuggingFace": {
"title": "קישור ל-HuggingFace",
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-דאטה באמצעות AI.",
"urlLabel": "כתובת URL של מאגר HuggingFace:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
"confirmAction": "שמור וקשר"
},
"relinkCivitai": {
"title": "קשר מחדש ל-Civitai",
"warning": "אזהרה:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "הגדרת דירוג התוכן נכשלה: {message}",
"relinkSuccess": "המודל קושר מחדש ל-Civitai בהצלחה",
"relinkFailed": "שגיאה: {message}",
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
"linkHfFailed": "שגיאה: {message}",
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
"missingHash": "ה-hash של המודל אינו זמין"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "Civitaiデータを更新",
"checkUpdates": "更新確認",
"relinkCivitai": "Civitaiに再リンク",
"linkModel": "モデルをリンク",
"linkCivitai": "Civitai にリンク",
"linkHuggingFace": "HuggingFace にリンク",
"copySyntax": "LoRA構文をコピー",
"copyFilename": "モデルファイル名をコピー",
"copyRecipeSyntax": "レシピ構文をコピー",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
"root": "ルート"
},
"linkHuggingFace": {
"title": "HuggingFace にリンク",
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
"urlLabel": "HuggingFace リポジトリ URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
"confirmAction": "保存&リンク"
},
"relinkCivitai": {
"title": "Civitaiに再リンク",
"warning": "警告:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
"relinkFailed": "エラー:{message}",
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
"linkHfFailed": "エラー:{message}",
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
"noCivitaiInfo": "CivitAI情報が利用できません",
"missingHash": "モデルハッシュが利用できません"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "Civitai 데이터 새로고침",
"checkUpdates": "업데이트 확인",
"relinkCivitai": "Civitai에 다시 연결",
"linkModel": "모델 연결",
"linkCivitai": "Civitai에 연결",
"linkHuggingFace": "HuggingFace에 연결",
"copySyntax": "LoRA 문법 복사",
"copyFilename": "모델 파일명 복사",
"copyRecipeSyntax": "레시피 문법 복사",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
"root": "루트"
},
"linkHuggingFace": {
"title": "HuggingFace에 연결",
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
"urlLabel": "HuggingFace 저장소 URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
"confirmAction": "저장 및 연결"
},
"relinkCivitai": {
"title": "Civitai에 다시 연결",
"warning": "경고:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "콘텐츠 등급 설정 실패: {message}",
"relinkSuccess": "모델이 Civitai에 성공적으로 다시 연결되었습니다",
"relinkFailed": "오류: {message}",
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
"linkHfFailed": "오류: {message}",
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
"missingHash": "모델 해시를 사용할 수 없습니다"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "Обновить данные Civitai",
"checkUpdates": "Проверить обновления",
"relinkCivitai": "Пересвязать с Civitai",
"linkModel": "Связать модель",
"linkCivitai": "Пересвязать с Civitai",
"linkHuggingFace": "Связать с HuggingFace",
"copySyntax": "Копировать синтаксис LoRA",
"copyFilename": "Копировать имя файла модели",
"copyRecipeSyntax": "Копировать синтаксис рецепта",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
"root": "Корень"
},
"linkHuggingFace": {
"title": "Связать с HuggingFace",
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
"urlLabel": "URL репозитория HuggingFace:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Введите полный URL репозитория HuggingFace.",
"confirmAction": "Сохранить и связать"
},
"relinkCivitai": {
"title": "Пересвязать с Civitai",
"warning": "Предупреждение:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "Не удалось установить рейтинг контента: {message}",
"relinkSuccess": "Модель успешно пересвязана с Civitai",
"relinkFailed": "Ошибка: {message}",
"linkHfSuccess": "Модель успешно связана с HuggingFace",
"linkHfFailed": "Ошибка: {message}",
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
"noCivitaiInfo": "Информация CivitAI недоступна",
"missingHash": "Хеш модели недоступен"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "刷新 Civitai 数据",
"checkUpdates": "检查更新",
"relinkCivitai": "重新关联到 Civitai",
"linkModel": "链接模型",
"linkCivitai": "链接到 Civitai",
"linkHuggingFace": "链接到 HuggingFace",
"copySyntax": "复制 LoRA 语法",
"copyFilename": "复制模型文件名",
"copyRecipeSyntax": "复制配方语法",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
"root": "根目录"
},
"linkHuggingFace": {
"title": "链接到 HuggingFace",
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
"urlLabel": "HuggingFace 仓库 URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
"confirmAction": "保存并链接"
},
"relinkCivitai": {
"title": "重新关联到 Civitai",
"warning": "警告:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "设置内容评级失败:{message}",
"relinkSuccess": "模型已成功重新关联到 Civitai",
"relinkFailed": "错误:{message}",
"linkHfSuccess": "模型已成功链接到 HuggingFace",
"linkHfFailed": "错误:{message}",
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
"noCivitaiInfo": "无 CivitAI 信息",
"missingHash": "模型哈希不可用"

View File

@@ -786,7 +786,9 @@
"contextMenu": {
"refreshMetadata": "刷新 Civitai 資料",
"checkUpdates": "檢查更新",
"relinkCivitai": "重新連結 Civitai",
"linkModel": "連結模型",
"linkCivitai": "連結到 Civitai",
"linkHuggingFace": "連結到 HuggingFace",
"copySyntax": "複製 LoRA 語法",
"copyFilename": "複製模型檔名",
"copyRecipeSyntax": "複製配方語法",
@@ -1319,6 +1321,14 @@
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
"root": "根目錄"
},
"linkHuggingFace": {
"title": "連結到 HuggingFace",
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
"urlLabel": "HuggingFace 倉庫 URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
"confirmAction": "儲存並連結"
},
"relinkCivitai": {
"title": "重新連結至 Civitai",
"warning": "警告:",
@@ -2048,6 +2058,8 @@
"contentRatingFailed": "設定內容分級失敗:{message}",
"relinkSuccess": "模型已成功重新連結至 Civitai",
"relinkFailed": "錯誤:{message}",
"linkHfSuccess": "模型已成功連結到 HuggingFace",
"linkHfFailed": "錯誤:{message}",
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
"noCivitaiInfo": "無 CivitAI 資訊",
"missingHash": "模型雜湊不可用"

View File

@@ -148,9 +148,117 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str, folder:
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
def _find_matching_root(dest_dir: str) -> str | None:
"""Walk up *dest_dir* to find which configured scanner root it belongs to."""
norm = os.path.normpath(dest_dir).replace(os.sep, "/")
all_roots = []
for root_list in (
config.loras_roots or [],
config.extra_loras_roots or [],
config.checkpoints_roots or [],
config.extra_checkpoints_roots or [],
config.unet_roots or [],
config.extra_unet_roots or [],
config.embeddings_roots or [],
config.extra_embeddings_roots or [],
):
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
# Find the longest matching prefix
match: str | None = None
for root in all_roots:
if norm.startswith(root):
if match is None or len(root) > len(match):
match = root
return match
async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> None:
model_dir = os.path.dirname(dest_path)
model_root = _find_matching_root(model_dir)
if not model_root:
raise ValueError(f"File path {dest_path} is not within any configured scanner root")
scanner_getter_name = _infer_model_type(model_root)[1]
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is None:
raise RuntimeError(f"Scanner getter '{scanner_getter_name}' not found in ServiceRegistry")
scanner = await scanner_getter()
if scanner is None:
raise RuntimeError(f"Scanner '{scanner_getter_name}' returned None")
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
class HfHandler:
"""Handle Hugging Face model browsing and download."""
async def set_hf_url(self, request: web.Request) -> web.Response:
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
file_path = (payload.get("file_path") or "").strip()
hf_url = (payload.get("hf_url") or "").strip()
if not file_path or not hf_url:
return web.json_response(
{"success": False, "error": "Missing required fields: 'file_path' and 'hf_url'"},
status=400,
)
m = re.match(r"^https?://huggingface\.co/([^/]+/[^/]+)/?$", hf_url)
if not m:
return web.json_response(
{
"success": False,
"error": "Invalid HuggingFace URL. Expected format: https://huggingface.co/user/repo",
},
status=400,
)
if not os.path.isfile(file_path):
return web.json_response(
{"success": False, "error": f"File not found: {file_path}"},
status=404,
)
model_root = _find_matching_root(os.path.dirname(file_path))
if not model_root:
return web.json_response(
{
"success": False,
"error": "File is not within any configured model directory. Cannot link to HuggingFace.",
},
status=400,
)
try:
existing = await MetadataManager.load_metadata_payload(file_path)
if existing.get("hf_url") == hf_url:
return web.json_response({
"success": True,
"message": "hf_url already set",
"hf_url": hf_url,
})
existing["hf_url"] = hf_url
existing["from_civitai"] = False
await MetadataManager.save_metadata(file_path, existing)
await _add_to_scanner_cache(file_path, existing)
logger.info("Set hf_url=%s for %s", hf_url, file_path)
return web.json_response({
"success": True,
"message": f"hf_url set to {hf_url}",
"hf_url": hf_url,
})
except Exception as exc:
logger.error("Failed to set hf_url for %s: %s", file_path, exc)
return web.json_response(
{"success": False, "error": str(exc)},
status=500,
)
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
"""List model-weight files from a HF repo with real file sizes.

View File

@@ -3448,6 +3448,7 @@ class MiscHandlerSet:
# Hugging Face handlers
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
"download_hf_model": self.hf_handler.download_hf_model,
"set_hf_url": self.hf_handler.set_hf_url,
# Agent skill handlers
"get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill,

View File

@@ -103,6 +103,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
RouteDefinition(
"POST", "/api/lm/set-hf-url", "set_hf_url"
),
# Agent skill endpoints
RouteDefinition(
"GET", "/api/lm/agent/skills", "get_agent_skills"

View File

@@ -21,18 +21,22 @@
margin-bottom: 4px;
}
.input-group {
#relinkCivitaiModal .input-group,
#linkHfModal .input-group {
display: flex;
flex-direction: column;
margin-bottom: var(--space-2);
}
.input-group label {
#relinkCivitaiModal .input-group label,
#linkHfModal .input-group label {
margin-bottom: var(--space-1);
font-weight: 500;
}
.input-group input {
#relinkCivitaiModal .input-group input,
#linkHfModal .input-group input {
width: auto;
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);

View File

@@ -32,6 +32,9 @@ export class LoraContextMenu extends BaseContextMenu {
if (!enrichItem) return;
const hasHfUrl = !!card.dataset.hf_url;
enrichItem.classList.toggle('disabled', !hasHfUrl);
enrichItem.title = hasHfUrl
? ''
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
}
handleMenuAction(action, menuItem) {

View File

@@ -187,6 +187,74 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50);
},
// HuggingFace linking methods
showLinkHfModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
const confirmBtn = document.getElementById('confirmLinkHfBtn');
const urlInput = document.getElementById('hfModelUrl');
const errorDiv = document.getElementById('hfModelUrlError');
if (this._boundLinkHfHandler) {
confirmBtn.removeEventListener('click', this._boundLinkHfHandler);
}
this._boundLinkHfHandler = async () => {
const hfUrl = urlInput.value.trim();
if (!hfUrl) {
errorDiv.textContent = 'Please enter a HuggingFace repository URL.';
return;
}
const hfPattern = /^https?:\/\/huggingface\.co\/([^/]+\/[^/]+)\/?$/;
if (!hfPattern.test(hfUrl)) {
errorDiv.textContent = 'Invalid URL format. Expected: https://huggingface.co/user/repo';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('linkHfModal');
try {
state.loadingManager.showSimpleLoading('Linking to HuggingFace...');
const response = await fetch('/api/lm/set-hf-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, hf_url: hfUrl }),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || `Request failed: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('toast.contextMenu.linkHfSuccess', {}, 'success');
await this.resetAndReload();
} else {
throw new Error(data.error || 'Failed to link model');
}
} catch (error) {
console.error('Error linking model to HuggingFace:', error);
showToast('toast.contextMenu.linkHfFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
}
};
confirmBtn.addEventListener('click', this._boundLinkHfHandler);
urlInput.value = '';
errorDiv.textContent = '';
modalManager.showModal('linkHfModal');
setTimeout(() => urlInput.focus(), 50);
},
extractModelVersionId(url) {
return extractCivitaiModelUrlParts(url);
},
@@ -295,6 +363,9 @@ export const ModelContextMenuMixin = {
case 'relink-civitai':
this.showRelinkCivitaiModal();
return true;
case 'link-hf':
this.showLinkHfModal();
return true;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
return true;

View File

@@ -264,6 +264,19 @@ export class ModalManager {
});
}
// Add linkHfModal registration
const linkHfModal = document.getElementById('linkHfModal');
if (linkHfModal) {
this.registerModal('linkHfModal', {
element: linkHfModal,
onClose: () => {
this.getModal('linkHfModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add exampleAccessModal registration
const exampleAccessModal = document.getElementById('exampleAccessModal');
if (exampleAccessModal) {

View File

@@ -12,7 +12,19 @@
<div id="checkpointContextMenu" class="context-menu" style="display: none;">
<!-- Metadata -->
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
<i class="fas fa-link"></i>
<span>{{ t('loras.contextMenu.linkModel') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
</div>
</div>
</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Workflow -->
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>

View File

@@ -12,8 +12,18 @@
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.contextMenu.checkUpdates') }}</span>
</div>
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-link"></i> <span>{{ t('loras.contextMenu.relinkCivitai') }}</span>
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
<i class="fas fa-link"></i>
<span>{{ t('loras.contextMenu.linkModel') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
</div>
</div>
</div>
<div class="context-menu-item" data-action="enrich-hf-llm">
<i class="fas fa-wand-magic-sparkles"></i> <span>{{ t('loras.contextMenu.enrichHfAgent') }}</span>

View File

@@ -8,6 +8,7 @@
{% include 'components/modals/update_modal.html' %}
{% include 'components/modals/help_modal.html' %}
{% include 'components/modals/relink_civitai_modal.html' %}
{% include 'components/modals/link_hf_modal.html' %}
{% include 'components/modals/example_access_modal.html' %}
{% include 'components/modals/download_modal.html' %}
{% include 'components/modals/move_modal.html' %}

View File

@@ -0,0 +1,24 @@
<!-- Link to HuggingFace Modal -->
<div id="linkHfModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('linkHfModal')">&times;</button>
<h2>{{ t('modals.linkHuggingFace.title') }}</h2>
<div class="warning-box">
<i class="fas fa-info-circle"></i>
<p>{{ t('modals.linkHuggingFace.infoText') }}</p>
</div>
<div class="input-group">
<label for="hfModelUrl">{{ t('modals.linkHuggingFace.urlLabel') }}</label>
<input type="text" id="hfModelUrl" placeholder="{{ t('modals.linkHuggingFace.urlPlaceholder') }}" />
<div class="input-error" id="hfModelUrlError"></div>
<div class="input-help">
{{ t('modals.linkHuggingFace.helpText') }}<br>
<strong>https://huggingface.co/user/repo</strong>
</div>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('linkHfModal')">{{ t('common.actions.cancel') }}</button>
<button class="confirm-btn" id="confirmLinkHfBtn">{{ t('modals.linkHuggingFace.confirmAction') }}</button>
</div>
</div>
</div>

View File

@@ -12,7 +12,19 @@
<div id="embeddingContextMenu" class="context-menu" style="display: none;">
<!-- Metadata -->
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
<i class="fas fa-link"></i>
<span>{{ t('loras.contextMenu.linkModel') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
</div>
</div>
</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Workflow -->
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>