waveshaker commited on
Commit
fecf342
·
verified ·
1 Parent(s): 394a063

Upload 2 files

Browse files
Files changed (2) hide show
  1. models_manager_v7.bat +15 -0
  2. models_manager_v7.py +1681 -0
models_manager_v7.bat ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ chcp 65001 >nul
3
+ title LoRA 仓库管理工具
4
+ cd /d "%~dp0"
5
+
6
+ python models_manager_v7.py
7
+
8
+ :: 检查上一句命令的返回值,如果非 0(代表报错崩溃了),就暂停
9
+ if %errorlevel% neq 0 (
10
+ echo.
11
+ echo ==========================================
12
+ echo ❌ 程序异常退出!错误码: %errorlevel%
13
+ echo ==========================================
14
+ pause
15
+ )
models_manager_v7.py ADDED
@@ -0,0 +1,1681 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # models_manager.py
2
+ import json
3
+ import os
4
+ import sys
5
+ import time
6
+ import threading
7
+ import traceback
8
+ import tkinter as tk
9
+ from tkinter import ttk, messagebox, filedialog
10
+ from tkinter.scrolledtext import ScrolledText
11
+ import urllib.request
12
+ import urllib.error
13
+ from pathlib import Path
14
+ import subprocess
15
+ import tempfile
16
+ import pandas as pd
17
+ from datasets import Dataset, Features, Image as HFImage, Value
18
+ from huggingface_hub import HfApi, DatasetCard
19
+ from PIL import Image
20
+ import os
21
+ import re
22
+ for k in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]:
23
+ os.environ.pop(k, None)
24
+
25
+ # ================= 配置区 =================
26
+ SITE_CONFIG_FILE = "site_config.json"
27
+ SPACE_ID = "waveshaker/loras_gallery"
28
+ MODEL_REPO_ID = "waveshaker/loras"
29
+ DATASET_REPO_ID = "waveshaker/dataset_collections"
30
+ DATASET_BASE = "https://huggingface.co/datasets/waveshaker/dataset_collections/tree/main"
31
+
32
+ OUTPUT_FILE = "models.json"
33
+ TOKEN_FILE = "hf_token.txt"
34
+ LOG_FILE = "models_manager.log"
35
+ HTTP_TIMEOUT = 20
36
+ # ==========================================
37
+
38
+ TOP_ORDER = {"krea2": 1, "anima": 2, "flux1_dev": 3, "pony": 4, "sd15": 5}
39
+ SUB_ORDER = {"CN": 1, "KR": 2, "JP": 3, "Fictional": 4, "WE": 5}
40
+ IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
41
+
42
+ def custom_sort_key(file_path):
43
+ parts = file_path.split('/')
44
+ top_dir = parts[0] if len(parts) > 0 else ""
45
+ sub_dir = parts[1] if len(parts) > 1 else ""
46
+ return (TOP_ORDER.get(top_dir, 999), SUB_ORDER.get(sub_dir, 999), file_path)
47
+
48
+ # ---------- 可选拖拽 ----------
49
+ try:
50
+ from tkinterdnd2 import DND_FILES, TkinterDnD
51
+ _HAS_DND = True
52
+ except Exception:
53
+ DND_FILES = None
54
+ TkinterDnD = None
55
+ _HAS_DND = False
56
+
57
+ class ModelsManager(tk.Tk):
58
+ def __init__(self):
59
+ super().__init__()
60
+ self.title("LoRA 仓库管理工具")
61
+ self.geometry("1250x850")
62
+
63
+ # 全局 Token
64
+ self.token_var = tk.StringVar(value=self._load_token())
65
+
66
+ # 🌟 新增:保存 site_config 数据
67
+ self.site_config_data = {"models": [], "categories": []}
68
+
69
+ # JSON 相关状态
70
+ self.items = {}
71
+ self.remote_files = []
72
+ self.new_paths = set()
73
+ self.stale_paths = set()
74
+ self._search_job = None
75
+ self._loading = False
76
+ self.api = None
77
+
78
+ # 🌟 Model 上传相关状态:自动获取当前脚本路径,并指向同级的 models 文件夹
79
+ if getattr(sys, 'frozen', False):
80
+ current_dir = os.path.dirname(sys.executable)
81
+ else:
82
+ current_dir = os.path.dirname(os.path.abspath(__file__))
83
+
84
+ default_models_dir = os.path.join(current_dir, "models")
85
+ if not os.path.exists(default_models_dir):
86
+ os.makedirs(default_models_dir, exist_ok=True)
87
+
88
+ self.local_folder_var = tk.StringVar(value=default_models_dir)
89
+ self.remote_dir_var = tk.StringVar()
90
+
91
+ # 全局 Token
92
+ self.token_var = tk.StringVar(value=self._load_token())
93
+
94
+ # ============ 初始化 UI ============
95
+ self._build_global_ui()
96
+ self._build_json_tab()
97
+ self._build_model_tab()
98
+ self._build_dataset_tab() # <--- 新增这一行
99
+ self._build_showcase_tab() # 🌟 加上这一行
100
+
101
+ self._refresh_api()
102
+ self._setup_dnd()
103
+ self.log_json("程序启动")
104
+
105
+ # ==========================================================
106
+ # ===================== 全局基础函数 =======================
107
+ # ==========================================================
108
+ def log_json(self, msg):
109
+ self._log_to_tab(msg, getattr(self, 'log_json_text', None))
110
+
111
+ def log_model(self, msg):
112
+ self._log_to_tab(msg, getattr(self, 'log_model_text', None))
113
+
114
+ def _log_to_tab(self, msg, target_text_widget):
115
+ if not target_text_widget: return
116
+ ts = time.strftime("%H:%M:%S")
117
+ line = f"[{ts}] {msg}"
118
+ try:
119
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
120
+ f.write(line + "\n")
121
+ except Exception: pass
122
+
123
+ def _append():
124
+ try:
125
+ target_text_widget.configure(state="normal")
126
+ target_text_widget.insert("end", line + "\n")
127
+ target_text_widget.see("end")
128
+ target_text_widget.configure(state="disabled")
129
+ except Exception: pass
130
+
131
+ if threading.current_thread() is threading.main_thread():
132
+ _append()
133
+ else:
134
+ self.after(0, _append)
135
+
136
+ def log_exc(self, prefix="", is_model=False):
137
+ tb = traceback.format_exc()
138
+ msg = f"{prefix}异常:\n{tb}"
139
+ if is_model:
140
+ self.log_model(msg)
141
+ else:
142
+ self.log_json(msg)
143
+
144
+ def _load_token(self):
145
+ env = os.environ.get("HF_TOKEN", "").strip()
146
+ if env: return env
147
+ try:
148
+ with open(TOKEN_FILE, "r", encoding="utf-8") as f:
149
+ return f.read().strip()
150
+ except FileNotFoundError:
151
+ return ""
152
+
153
+ def _save_token(self, token):
154
+ if token:
155
+ with open(TOKEN_FILE, "w", encoding="utf-8") as f: f.write(token)
156
+ else:
157
+ try: os.remove(TOKEN_FILE)
158
+ except FileNotFoundError: pass
159
+
160
+ def _refresh_api(self):
161
+ token = self.token_var.get().strip()
162
+ self.api = HfApi(token=token) if token else HfApi()
163
+
164
+ def _ensure_token(self):
165
+ token = self.token_var.get().strip()
166
+ if not token:
167
+ messagebox.showwarning("需要 Token", "请先在上方填写 HF Token 再操作。")
168
+ self.log_json("操作中止:未填写 Token")
169
+ return None
170
+ self._refresh_api()
171
+ return token
172
+
173
+ def _build_global_ui(self):
174
+ # 1. 顶部 Token 行
175
+ token_row = ttk.Frame(self)
176
+ token_row.pack(fill="x", padx=8, pady=(8, 2))
177
+ ttk.Label(token_row, text="HF Token:").pack(side="left")
178
+ ttk.Entry(token_row, textvariable=self.token_var, width=60, show="*").pack(side="left", padx=6)
179
+ ttk.Button(token_row, text="📥 保存 Token", command=self._on_save_token).pack(side="left")
180
+ ttk.Button(token_row, text="❌ 清除", command=self._on_clear_token).pack(side="left", padx=4)
181
+
182
+ ttk.Button(token_row, text="📄 打开日志", command=self.open_log_file).pack(side="right", padx=6)
183
+
184
+ # 1. 恢复 Windows 默认主题(看起来最舒服)
185
+ style = ttk.Style()
186
+ # 如果你在 Windows 11 上,可以用 'vista' 或 'winnative',如果想要更现代可以试试 'xpnative'
187
+ if sys.platform.startswith("win"):
188
+ style.theme_use('vista')
189
+
190
+ # 2. 强制扁平化 Tab 的渲染结构(屏蔽掉系统的边框和虚线焦点)
191
+ style.layout("TNotebook.Tab", [
192
+ ('Notebook.tab', {'sticky': 'nswe', 'children': [
193
+ ('Notebook.padding', {'side': 'top', 'sticky': 'nswe', 'children': [
194
+ ('Notebook.label', {'side': 'top', 'sticky': ''})
195
+ ]})
196
+ ]})
197
+ ])
198
+
199
+ # 3. 基础设置
200
+ style.configure("TNotebook.Tab",
201
+ padding=[12, 6],
202
+ font=("Microsoft YaHei", 9),
203
+ borderwidth=0,
204
+ background="#f0f0f0",
205
+ foreground="#333333"
206
+ )
207
+
208
+ # 4. 锁定所有状态,防止任何像素级跳动
209
+ style.map("TNotebook.Tab",
210
+ padding=[("selected", [12, 6]), ("active", [12, 6]), ("!selected", [12, 6])],
211
+ borderwidth=[("selected", 0), ("active", 0), ("!selected", 0)],
212
+ # 焦点框设为空,去掉点击时的虚线框
213
+ focuscolor=[("selected", "none"), ("active", "none"), ("!selected", "none")],
214
+ # 选中和悬停时的颜色
215
+ background=[("selected", "#e1f5fe"), ("active", "#f5f5f5")],
216
+ foreground=[("selected", "#0277bd")]
217
+ )
218
+
219
+ # 3. 创建 Notebook 和 Tab 页面
220
+ self.notebook = ttk.Notebook(self)
221
+ self.notebook.pack(fill="both", expand=True, padx=8, pady=4)
222
+
223
+ self.tab_json = ttk.Frame(self.notebook)
224
+ self.tab_model = ttk.Frame(self.notebook)
225
+ self.tab_dataset = ttk.Frame(self.notebook)
226
+
227
+ # 4. 🌟 添加 Tab(Emoji + 文字)
228
+ self.notebook.add(self.tab_json, text=" 🚀 Space - JSON 管理 ")
229
+ self.notebook.add(self.tab_model, text=" 🧊 Models - 模型上传 ")
230
+ self.notebook.add(self.tab_dataset, text=" 📚 Dataset - 数据集上传 ")
231
+ self.tab_showcase = ttk.Frame(self.notebook)
232
+ self.notebook.add(self.tab_showcase, text=" ✨ Showcase - 案例上传 ")
233
+
234
+ def _on_save_token(self):
235
+ self._save_token(self.token_var.get().strip())
236
+ self._refresh_api()
237
+ self.log_json("Token 已保存")
238
+ messagebox.showinfo("完成", "Token 已保存到本地")
239
+
240
+ def _on_clear_token(self):
241
+ self.token_var.set("")
242
+ self._save_token("")
243
+ self._refresh_api()
244
+ self.log_json("Token 已清除")
245
+ messagebox.showinfo("完成", "Token 已清除")
246
+
247
+ def open_log_file(self):
248
+ try: os.startfile(LOG_FILE)
249
+ except Exception:
250
+ try:
251
+ import subprocess
252
+ subprocess.Popen(["notepad", LOG_FILE])
253
+ except Exception as e:
254
+ messagebox.showerror("错误", f"无法打开日志: {e}")
255
+
256
+ def _setup_dnd(self):
257
+ if not _HAS_DND: return
258
+ try:
259
+ TkinterDnD._require(self)
260
+ self.tree.drop_target_register(DND_FILES)
261
+ self.tree.dnd_bind("<<Drop>>", self._on_drop_json)
262
+ except Exception as e:
263
+ self.log_json(f"DnD 初始化���败: {e}")
264
+
265
+
266
+ # ==========================================================
267
+ # ==================== 区域 1: JSON 管理 ===================
268
+ # ==========================================================
269
+ def _build_json_tab(self):
270
+ parent = self.tab_json
271
+
272
+ top = ttk.Frame(parent)
273
+ top.pack(fill="x", padx=8, pady=4)
274
+ self.btn_load_remote = ttk.Button(top, text="🔄 拉取 models.json", command=self.load_remote)
275
+ self.btn_load_remote.pack(side="left")
276
+ ttk.Button(top, text="📂 加载本地 models.json", command=self.load_local).pack(side="left", padx=6)
277
+ ttk.Button(top, text="💾 保存 models.json 到本地", command=self.save_local).pack(side="left")
278
+ ttk.Button(top, text="🢁 上传 models.json", command=self.upload_models_json).pack(side="left", padx=6)
279
+ # 🌟 新增:配置同步按钮
280
+ ttk.Button(top, text="🌐 打开 Dataset Repo", compound="left", command=self.open_dataset_repo).pack(side="right", padx=6)
281
+ ttk.Button(top, text="⚙️ 编辑 site_config", command=self.open_site_config_editor).pack(side="right", padx=12)
282
+
283
+
284
+ filt = ttk.Frame(parent)
285
+ filt.pack(fill="x", padx=8)
286
+ ttk.Label(filt, text="🔍 搜索:").pack(side="left")
287
+ self.search_var = tk.StringVar()
288
+ self.search_var.trace_add("write", lambda *_: self._debounced_refresh())
289
+ ttk.Entry(filt, textvariable=self.search_var, width=30).pack(side="left", padx=4)
290
+
291
+ self.only_new = tk.BooleanVar(value=False)
292
+ ttk.Checkbutton(filt, text="只看新增", variable=self.only_new, command=self.refresh_tree).pack(side="left", padx=6)
293
+ self.only_empty_trigger = tk.BooleanVar(value=False)
294
+ ttk.Checkbutton(filt, text="只看 trigger 为空", variable=self.only_empty_trigger, command=self.refresh_tree).pack(side="left")
295
+
296
+ body = ttk.Panedwindow(parent, orient="vertical")
297
+ body.pack(fill="both", expand=True, padx=8, pady=6)
298
+
299
+ tree_frame = ttk.Frame(body)
300
+ cols = ("title", "trigger", "dataset", "showcase") # 🌟 新增 showcase 列
301
+ self.tree = ttk.Treeview(tree_frame, columns=cols, show="tree headings", height=20)
302
+ self.tree.heading("#0", text="路径")
303
+ self.tree.heading("title", text="title")
304
+ self.tree.heading("trigger", text="trigger")
305
+ self.tree.heading("dataset", text="dataset")
306
+ self.tree.heading("showcase", text="showcase") # 🌟 新增
307
+
308
+ self.tree.column("#0", width=380, stretch=True)
309
+ self.tree.column("title", width=180)
310
+ self.tree.column("trigger", width=260)
311
+ self.tree.column("dataset", width=240)
312
+ self.tree.column("showcase", width=200) # 🌟 加入 showcase
313
+
314
+ vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
315
+ self.tree.configure(yscrollcommand=vsb.set)
316
+ self.tree.pack(side="left", fill="both", expand=True)
317
+ vsb.pack(side="left", fill="y")
318
+ self.tree.tag_configure("new", background="#d4f5d4")
319
+ self.tree.tag_configure("empty_trigger", background="#fff3c4")
320
+ self.tree.tag_configure("stale", foreground="#999999")
321
+ self.tree.bind("<Double-1>", self.on_double_click)
322
+
323
+ log_frame = ttk.LabelFrame(body, text="JSON 操作日志")
324
+ self.log_json_text = ScrolledText(log_frame, height=10, state="disabled", wrap="word")
325
+ self.log_json_text.pack(fill="both", expand=True)
326
+
327
+ body.add(tree_frame, weight=3)
328
+ body.add(log_frame, weight=1)
329
+
330
+ def open_dataset_repo(self):
331
+ import webbrowser
332
+ webbrowser.open(f"https://huggingface.co/datasets/{DATASET_REPO_ID}/tree/main")
333
+
334
+ def _debounced_refresh(self):
335
+ if self._search_job: self.after_cancel(self._search_job)
336
+ self._search_job = self.after(250, self.refresh_tree)
337
+
338
+ def load_local(self):
339
+ self.log_json(f"加载本地 {OUTPUT_FILE}")
340
+ try:
341
+ with open(OUTPUT_FILE, "r", encoding="utf-8") as f:
342
+ data = json.load(f)
343
+ except FileNotFoundError:
344
+ messagebox.showwarning("提示", f"本地没有 {OUTPUT_FILE}")
345
+ return
346
+ except Exception as e:
347
+ self.log_json(f"读取失败: {e}")
348
+ return
349
+
350
+ self.items = data.get("items", {})
351
+ if not self.items and "titles" in data:
352
+ self.items = {k: {"title": v, "trigger": "", "dataset": ""} for k, v in data["titles"].items()}
353
+ self.new_paths = set()
354
+ self.stale_paths = set()
355
+ self.refresh_tree()
356
+ self.log_json(f"本地加载完成,{len(self.items)} 条")
357
+
358
+ def load_remote(self):
359
+ if self._loading: return
360
+ self._loading = True
361
+ self.btn_load_remote.configure(state="disabled")
362
+ token = self.token_var.get().strip()
363
+ self.log_json(f"开始拉取远端…")
364
+ threading.Thread(target=self._load_remote_worker, args=(token,), daemon=True).start()
365
+
366
+ def _load_remote_worker(self, token):
367
+ try:
368
+ api = HfApi(token=token) if token else HfApi()
369
+ t0 = time.time()
370
+ all_files = list(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
371
+ remote_files = [f for f in all_files if f.endswith(".safetensors")]
372
+ self.log_json(f"步骤1: 远端文件 {len(all_files)},safetensors {len(remote_files)},{time.time()-t0:.2f}s")
373
+
374
+ space_subdomain = SPACE_ID.replace("/", "-").replace("_", "-").lower()
375
+ url = f"https://{space_subdomain}.static.hf.space/models.json"
376
+ remote_data = None
377
+ try:
378
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
379
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as response:
380
+ remote_data = json.loads(response.read().decode())
381
+ self.log_json(f"步骤2: JSON 获取成功")
382
+ except Exception as e:
383
+ self.log_json(f"步骤2 失败: {type(e).__name__}: {e}")
384
+
385
+ existing_items = {}
386
+ if remote_data:
387
+ existing_items = remote_data.get("items", {})
388
+ if not existing_items and "titles" in remote_data:
389
+ existing_items = {k: {"title": v, "trigger": "", "dataset": ""} for k, v in remote_data["titles"].items()}
390
+
391
+ merged, new_paths = {}, set()
392
+ remote_set = set(remote_files)
393
+ for path in remote_files:
394
+ if path in existing_items:
395
+ merged[path] = existing_items[path]
396
+ else:
397
+ merged[path] = {"title": "", "trigger": "", "dataset": ""}
398
+ new_paths.add(path)
399
+ stale_paths = set(existing_items.keys()) - remote_set
400
+ for p in stale_paths:
401
+ merged[p] = existing_items[p]
402
+
403
+ items = {k: merged[k] for k in sorted(merged.keys(), key=custom_sort_key)}
404
+ self.log_json(f"步骤3: 合并 {len(items)} 条,新增 {len(new_paths)},失效 {len(stale_paths)}")
405
+
406
+ self.after(0, lambda: self._apply_remote(items, remote_files, new_paths, stale_paths))
407
+ except Exception:
408
+ self.log_exc("拉取失败")
409
+ self.after(0, lambda: messagebox.showerror("错误", "拉取失败,详见日志"))
410
+ finally:
411
+ self.after(0, self._finish_loading)
412
+
413
+ def _apply_remote(self, items, remote_files, new_paths, stale_paths):
414
+ self.items = items
415
+ self.remote_files = remote_files
416
+ self.new_paths = new_paths
417
+ self.stale_paths = stale_paths
418
+ self.refresh_tree()
419
+
420
+ def _finish_loading(self):
421
+ self._loading = False
422
+ self.btn_load_remote.configure(state="normal")
423
+
424
+ def refresh_tree(self):
425
+ self.tree.delete(*self.tree.get_children())
426
+ kw = self.search_var.get().strip().lower()
427
+ only_new = self.only_new.get()
428
+ only_empty = self.only_empty_trigger.get()
429
+
430
+ for path, meta in self.items.items():
431
+ title = meta.get("title", "")
432
+ trigger = meta.get("trigger", "")
433
+ if kw and kw not in path.lower() and kw not in str(title).lower(): continue
434
+ if only_new and path not in self.new_paths: continue
435
+ if only_empty and trigger not in ("", [], None): continue
436
+
437
+ tags = []
438
+ if path in self.new_paths: tags.append("new")
439
+ elif path in self.stale_paths: tags.append("stale")
440
+ if trigger in ("", [], None): tags.append("empty_trigger")
441
+
442
+ trig_display = trigger if isinstance(trigger, str) else " | ".join(map(str, trigger))
443
+ self.tree.insert("", "end", iid=path, text=path, values=(title, trig_display, meta.get("dataset", ""), meta.get("showcase", "")), tags=tags)
444
+
445
+ def on_double_click(self, event):
446
+ sel = self.tree.focus()
447
+ if sel and sel in self.items:
448
+ self.open_manager(sel)
449
+
450
+ def open_manager(self, path):
451
+ meta = self.items[path]
452
+ win = tk.Toplevel(self)
453
+ win.title(f"编辑: {path}")
454
+ win.geometry("720x560")
455
+ win.transient(self); win.grab_set()
456
+
457
+ ttk.Label(win, text="路径").pack(anchor="w", padx=10, pady=(10, 0))
458
+ p_entry = ttk.Entry(win, width=90)
459
+ p_entry.insert(0, path); p_entry.configure(state="readonly"); p_entry.pack(padx=10, fill="x")
460
+
461
+ ttk.Label(win, text="title").pack(anchor="w", padx=10, pady=(8, 0))
462
+ title_var = tk.StringVar(value=meta.get("title", ""))
463
+ ttk.Entry(win, textvariable=title_var, width=90).pack(padx=10, fill="x")
464
+
465
+ ttk.Label(win, text="trigger").pack(anchor="w", padx=10, pady=(8, 0))
466
+ trig_text = tk.Text(win, height=8, wrap="word"); trig_text.pack(padx=10, fill="both")
467
+ trig = meta.get("trigger", "")
468
+ trig_text.insert("1.0", "\n".join(map(str, trig)) if isinstance(trig, list) else trig)
469
+
470
+ ttk.Label(win, text="dataset").pack(anchor="w", padx=10, pady=(8, 0))
471
+ ds_var = tk.StringVar(value=meta.get("dataset", ""))
472
+ ttk.Entry(win, textvariable=ds_var, width=90).pack(padx=10, fill="x")
473
+
474
+ # 🌟 新增:showcase 输入框
475
+ ttk.Label(win, text="showcase (URL)").pack(anchor="w", padx=10, pady=(8, 0))
476
+ sc_var = tk.StringVar(value=meta.get("showcase", ""))
477
+ ttk.Entry(win, textvariable=sc_var, width=90).pack(padx=10, fill="x")
478
+
479
+ def auto_dataset():
480
+ parts = path.split("/")
481
+ if len(parts) >= 3:
482
+ top, sub = parts[0], parts[1]
483
+ base = Path(parts[2]).stem
484
+ for noise in ("_onetrainer", "_Flux_512", "_Flux", "_512"):
485
+ base = base.replace(noise, "")
486
+ ds_var.set(f"{DATASET_BASE}/{top}/{sub}/{base}")
487
+
488
+ ttk.Button(win, text="自动生成 dataset", command=auto_dataset).pack(anchor="w", padx=10, pady=4)
489
+
490
+ def save_and_close():
491
+ title = title_var.get().strip()
492
+ raw = trig_text.get("1.0", "end").strip()
493
+ trigger_val = ([l.strip() for l in raw.splitlines() if l.strip()] if "\n" in raw else raw)
494
+ self.items[path] = {"title": title, "trigger": trigger_val, "dataset": ds_var.get().strip(),"showcase": sc_var.get().strip()}
495
+ self.refresh_tree()
496
+ win.destroy()
497
+
498
+ btn = ttk.Frame(win); btn.pack(fill="x", pady=10, padx=10)
499
+ ttk.Button(btn, text="保存", command=save_and_close).pack(side="right")
500
+ ttk.Button(btn, text="取消", command=win.destroy).pack(side="right", padx=6)
501
+
502
+ def save_local(self):
503
+ data_items = {k: v for k, v in self.items.items() if k not in self.stale_paths}
504
+ sorted_items = {k: data_items[k] for k in sorted(data_items.keys(), key=custom_sort_key)}
505
+ with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
506
+ json.dump({"items": sorted_items}, f, indent=2, ensure_ascii=False)
507
+ self.log_json(f"已保存本地 {OUTPUT_FILE},{len(sorted_items)} 条")
508
+ messagebox.showinfo("完成", f"已保存到 {OUTPUT_FILE}")
509
+
510
+ def upload_models_json(self):
511
+ if not self._ensure_token(): return
512
+ if not messagebox.askyesno("确认", f"上传 models.json 到 {SPACE_ID}?"): return
513
+
514
+ data_items = {k: v for k, v in self.items.items() if k not in self.stale_paths}
515
+ sorted_items = {k: data_items[k] for k in sorted(data_items.keys(), key=custom_sort_key)}
516
+
517
+ tmp = "models.upload.json"
518
+ with open(tmp, "w", encoding="utf-8") as f:
519
+ json.dump({"items": sorted_items}, f, indent=2, ensure_ascii=False)
520
+
521
+ self.log_json(f"开始上传 JSON ({len(sorted_items)} 条)")
522
+ api = self.api
523
+
524
+ def work():
525
+ try:
526
+ api.upload_file(path_or_fileobj=tmp, path_in_repo="models.json", repo_id=SPACE_ID, repo_type="space")
527
+ self.log_json("models.json 上传成功")
528
+ self.after(0, lambda: messagebox.showinfo("完成", "JSON 上传成功"))
529
+ except Exception:
530
+ self.log_exc("JSON 上传失败")
531
+ self.after(0, lambda: messagebox.showerror("失败", "详见日志"))
532
+ finally:
533
+ try: os.remove(tmp)
534
+ except OSError: pass
535
+
536
+ threading.Thread(target=work, daemon=True).start()
537
+
538
+ # ==========================================================
539
+ # ================= 新增:site_config 管理 =================
540
+ # ==========================================================
541
+ def open_site_config_editor(self):
542
+ if not self._ensure_token(): return
543
+
544
+ self.log_json("正在从 Space 拉取 site_config.json...")
545
+
546
+ def fetch_worker():
547
+ try:
548
+ # 直接从 Space 文件仓库下载
549
+ file_path = self.api.hf_hub_download(
550
+ repo_id=SPACE_ID,
551
+ filename=SITE_CONFIG_FILE,
552
+ repo_type="space"
553
+ )
554
+ with open(file_path, "r", encoding="utf-8") as f:
555
+ config_data = json.load(f)
556
+
557
+ self.after(0, lambda: self._show_config_window(config_data))
558
+ except Exception as e:
559
+ self.log_json(f"拉取 site_config 失败: {e}")
560
+ # 如果远端没有,就初始化一个空的
561
+ self.after(0, lambda: self._show_config_window({"models": [], "categories": []}))
562
+
563
+ threading.Thread(target=fetch_worker, daemon=True).start()
564
+
565
+ def _show_config_window(self, config_data):
566
+ win = tk.Toplevel(self)
567
+ win.title("编辑 site_config.json (保存后自动上传)")
568
+ win.geometry("600x550")
569
+ win.transient(self); win.grab_set()
570
+
571
+ ttk.Label(win, text="💡 提示:修改顺序即可改变前端左侧导航栏的展示顺序。", foreground="gray").pack(pady=5)
572
+
573
+ # 分成两个框:Models 和 Categories
574
+ frame_models = ttk.LabelFrame(win, text="Base Models (按顺序)")
575
+ frame_models.pack(fill="both", expand=True, padx=10, pady=5)
576
+
577
+ text_models = tk.Text(frame_models, height=10, wrap="none")
578
+ text_models.pack(fill="both", expand=True, padx=5, pady=5)
579
+ # 将 JSON 格式化为易读的字符串填入
580
+ text_models.insert("1.0", json.dumps(config_data.get("models", []), indent=2, ensure_ascii=False))
581
+
582
+ frame_cats = ttk.LabelFrame(win, text="Categories (按顺序)")
583
+ frame_cats.pack(fill="both", expand=True, padx=10, pady=5)
584
+
585
+ text_cats = tk.Text(frame_cats, height=10, wrap="none")
586
+ text_cats.pack(fill="both", expand=True, padx=5, pady=5)
587
+ text_cats.insert("1.0", json.dumps(config_data.get("categories", []), indent=2, ensure_ascii=False))
588
+
589
+ def save_and_upload():
590
+ try:
591
+ # 解析用户修改后的文本
592
+ new_models = json.loads(text_models.get("1.0", "end").strip())
593
+ new_cats = json.loads(text_cats.get("1.0", "end").strip())
594
+
595
+ new_config = {
596
+ "models": new_models,
597
+ "categories": new_cats
598
+ }
599
+
600
+ # 保存到本地临时文件
601
+ with open(SITE_CONFIG_FILE, "w", encoding="utf-8") as f:
602
+ json.dump(new_config, f, indent=2, ensure_ascii=False)
603
+
604
+ self.log_json("正在上传 site_config.json 到 Space...")
605
+ self.api.upload_file(
606
+ path_or_fileobj=SITE_CONFIG_FILE,
607
+ path_in_repo=SITE_CONFIG_FILE,
608
+ repo_id=SPACE_ID,
609
+ repo_type="space"
610
+ )
611
+ self.log_json("🎉 site_config.json 上传成功!")
612
+ messagebox.showinfo("成功", "配置已更新并上传到 Space!")
613
+ win.destroy()
614
+ except json.JSONDecodeError as e:
615
+ messagebox.showerror("JSON 格式错误", f"请检查括号、逗号是否漏了。\n报错信息: {e}")
616
+ except Exception as e:
617
+ self.log_json(f"上传失败: {e}")
618
+ messagebox.showerror("错误", f"上传失败: {e}")
619
+
620
+ btn_frame = ttk.Frame(win)
621
+ btn_frame.pack(fill="x", pady=10, padx=10)
622
+ ttk.Button(btn_frame, text="保存并上传", command=save_and_upload).pack(side="right")
623
+ ttk.Button(btn_frame, text="取消", command=win.destroy).pack(side="right", padx=6)
624
+
625
+ def _on_drop_json(self, event):
626
+ raw = event.data.strip("{}")
627
+ p = Path(raw)
628
+ if p.is_dir():
629
+ self.notebook.select(self.tab_model)
630
+ self.local_folder_var.set(str(p))
631
+
632
+
633
+ # ==========================================================
634
+ # ==================== 区域 2: 模型上传 ====================
635
+ # ==========================================================
636
+ def _build_model_tab(self):
637
+ parent = self.tab_model
638
+
639
+ # 1. 输入区
640
+ frame = ttk.Frame(parent)
641
+ frame.pack(fill="x", padx=10, pady=10)
642
+
643
+ # 本地文件夹
644
+ ttk.Label(frame, text="本地文件夹:").grid(row=0, column=0, sticky="w", pady=4)
645
+ ttk.Entry(frame, textvariable=self.local_folder_var, width=70).grid(row=0, column=1, padx=6, sticky="we")
646
+ ttk.Button(frame, text="📁 浏览...", width=18, command=self._browse_model_local).grid(row=0, column=2)
647
+
648
+ # 远端目录 (Treeview 改造)
649
+ ttk.Label(frame, text="上传到 HF 目录:").grid(row=1, column=0, sticky="nw", pady=4)
650
+
651
+ tree_frame = ttk.Frame(frame)
652
+ tree_frame.grid(row=1, column=1, padx=6, sticky="nsew")
653
+
654
+ # 滚动条
655
+ vsb = ttk.Scrollbar(tree_frame, orient="vertical")
656
+ vsb.pack(side="right", fill="y")
657
+
658
+ # 树形列表
659
+ self.remote_tree = ttk.Treeview(tree_frame, height=6, show="tree",
660
+ yscrollcommand=vsb.set, selectmode="browse")
661
+ self.remote_tree.pack(side="left", fill="both", expand=True)
662
+ vsb.config(command=self.remote_tree.yview)
663
+
664
+ # 绑定树形点击和双击事件
665
+ self.remote_tree.bind("<<TreeviewSelect>>", self._on_remote_tree_select)
666
+ self.remote_tree.bind("<Double-1>", lambda e: self._start_model_upload())
667
+
668
+ # 刷新按钮
669
+ ttk.Button(frame, text="🔄 拉取 Models 目录", width=18, command=self._fetch_hf_dirs).grid(row=1, column=2, sticky="n")
670
+
671
+ # 按钮区
672
+ btn_row = ttk.Frame(frame)
673
+ btn_row.grid(row=2, column=0, columnspan=3, pady=15, sticky="w")
674
+
675
+ # 🌟 开始上传按钮 (绑定 self.btn_upload,方便后面禁用/恢复)
676
+ self.btn_upload = ttk.Button(btn_row, text="🢁 开始上传", command=self._start_model_upload)
677
+ self.btn_upload.pack(side="left")
678
+
679
+ # 状态文字
680
+ self.status_model_var = tk.StringVar(value="就绪")
681
+ ttk.Label(btn_row, textvariable=self.status_model_var).pack(side="left", padx=10)
682
+
683
+ # 🌟 进度条
684
+ self.progress_var = tk.DoubleVar()
685
+ self.progress_bar = ttk.Progressbar(btn_row, variable=self.progress_var, maximum=100, length=200, mode='determinate')
686
+ self.progress_bar.pack(side="left", padx=10)
687
+
688
+ frame.columnconfigure(1, weight=1)
689
+ # 让 Treeview 高度自适应
690
+ frame.rowconfigure(1, weight=1)
691
+
692
+ # 2. 日志区
693
+ log_frame = ttk.LabelFrame(parent, text="模型上传日志")
694
+ log_frame.pack(fill="both", expand=True, padx=10, pady=6)
695
+ self.log_model_text = ScrolledText(log_frame, height=10, state="disabled", wrap="word")
696
+ self.log_model_text.pack(fill="both", expand=True)
697
+
698
+
699
+ def _browse_model_local(self):
700
+ # 🌟 强制从当前脚本所在目录开始(即 zz_huggingface 这一层)
701
+ if getattr(sys, 'frozen', False):
702
+ current_dir = os.path.dirname(sys.executable)
703
+ else:
704
+ current_dir = os.path.dirname(os.path.abspath(__file__))
705
+
706
+ folder = filedialog.askdirectory(title="选择包含 .safetensors 的文件夹", initialdir=current_dir)
707
+ if folder:
708
+ self.local_folder_var.set(folder)
709
+
710
+ def _fetch_hf_dirs(self):
711
+ if not self._ensure_token(): return
712
+ self.status_model_var.set("正在读取远端目录...")
713
+ self.log_model("正在读取远端仓库目录列表...")
714
+
715
+ def work():
716
+ try:
717
+ api = self.api
718
+ all_files = list(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
719
+ dirs = set()
720
+ for f in all_files:
721
+ d = str(Path(f).parent)
722
+ if d != ".":
723
+ dirs.add(d)
724
+
725
+ sorted_dirs = sorted(list(dirs))
726
+ self.after(0, lambda: self._update_remote_tree(sorted_dirs))
727
+ except Exception:
728
+ self.log_exc("读取远端目录失败", is_model=True)
729
+ finally:
730
+ self.after(0, lambda: self.status_model_var.set("就绪"))
731
+
732
+ threading.Thread(target=work, daemon=True).start()
733
+
734
+ def _update_remote_tree(self, dirs):
735
+ # 清空旧的树
736
+ for item in self.remote_tree.get_children():
737
+ self.remote_tree.delete(item)
738
+
739
+ tree_nodes = {} # 用于记录路径对应的 treeview item id
740
+
741
+ for d in dirs:
742
+ # 把 Windows 反斜杠统一替换成正斜杠,方便拆分
743
+ safe_d = d.replace("\\", "/")
744
+ parts = safe_d.split("/")
745
+
746
+ parent_id = ""
747
+ current_path = ""
748
+
749
+ for i, part in enumerate(parts):
750
+ current_path = f"{current_path}/{part}" if current_path else part
751
+
752
+ # 如果这个节点还没创建,就创建它
753
+ if current_path not in tree_nodes:
754
+ # 判断是不是最后一级目录(如果是最后一级,加上图标和展开符)
755
+ is_leaf = (i == len(parts) - 1)
756
+ display_text = f"📁 {part}" if is_leaf else part
757
+
758
+ node_id = self.remote_tree.insert(
759
+ parent_id, "end",
760
+ text=display_text,
761
+ values=(current_path,) # 把真实路径藏在 values 里
762
+ )
763
+ tree_nodes[current_path] = node_id
764
+
765
+ # 下一级的父节点变成了当前节点
766
+ parent_id = tree_nodes[current_path]
767
+
768
+ # 默认展开所有根节点
769
+ for root_node in self.remote_tree.get_children(""):
770
+ self.remote_tree.item(root_node, open=True)
771
+
772
+ self.log_model(f"已加载 {len(dirs)} 个远端目录结构")
773
+
774
+ def _on_remote_tree_select(self, event):
775
+ # 当用户在树上点击时,提取路径存到 self.remote_dir_var
776
+ selection = self.remote_tree.selection()
777
+ if selection:
778
+ item_id = selection[0]
779
+ # 从 values 里拿我们存的真实路径
780
+ values = self.remote_tree.item(item_id, "values")
781
+ if values:
782
+ self.remote_dir_var.set(values[0])
783
+
784
+
785
+ def _start_model_upload(self):
786
+ local_dir = self.local_folder_var.get().strip()
787
+ # 如果用户没点选树,默认上传到根目录;如果点了,就是选中的路径
788
+ remote_dir = self.remote_dir_var.get().strip().strip("/")
789
+
790
+ if not local_dir or not os.path.exists(local_dir):
791
+ messagebox.showwarning("错误", "请选择有效的本地文件夹。")
792
+ return
793
+ if not remote_dir:
794
+ if not messagebox.askyesno("确认", "远端目录为空,将上传到仓库根目录,继续?"):
795
+ return
796
+ if not self._ensure_token(): return
797
+
798
+ self.status_model_var.set("正在扫描并比对文件...")
799
+ self.log_model(f"开始扫描: {local_dir} -> {MODEL_REPO_ID}/{remote_dir}")
800
+
801
+ def scan_worker():
802
+ try:
803
+ api = self.api
804
+
805
+ # 1. 清洗本地图片 (png/webp -> jpg)
806
+ local_files = [f for f in Path(local_dir).iterdir() if f.is_file()]
807
+ for f in local_files:
808
+ if f.suffix.lower() in [".png", ".webp"]:
809
+ jpg_path = f.with_suffix(".jpg")
810
+ try:
811
+ self.log_model(f"清洗图片并转码: {f.name} -> {jpg_path.name}")
812
+ with Image.open(f) as img:
813
+ if img.mode in ("RGBA", "P"):
814
+ img = img.convert("RGB")
815
+ img.save(jpg_path, format="JPEG", quality=95)
816
+ os.remove(f) # 清理原图
817
+ except Exception as e:
818
+ self.log_model(f"转码失败 {f.name}: {e}")
819
+
820
+ # 2. 重新读取清洗后的文件列表
821
+ local_files = [f for f in Path(local_dir).iterdir() if f.is_file()]
822
+ if not local_files:
823
+ self.after(0, lambda: messagebox.showwarning("提示", "本地文件夹是空的。"))
824
+ return
825
+
826
+ # 3. 拉取远端文件用于比对
827
+ remote_all = set(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
828
+
829
+ to_upload = []
830
+ for f in local_files:
831
+ if f.suffix.lower() not in [".safetensors"] + list(IMG_EXTS):
832
+ continue
833
+
834
+ remote_path = f"{remote_dir}/{f.name}" if remote_dir else f.name
835
+ if remote_path not in remote_all:
836
+ to_upload.append((f, remote_path))
837
+
838
+ if not to_upload:
839
+ self.after(0, lambda: messagebox.showinfo("完成", "远端已存在所有文件,无需上传。"))
840
+ self.after(0, lambda: self.status_model_var.set("无需上传"))
841
+ self.log_model("扫描完毕,无新增文件。")
842
+ return
843
+
844
+ msg = f"本地清理完毕。找到 {len(to_upload)} 个新文件待上传。\n\n目标目录: {remote_dir}\n\n继续?"
845
+ self.after(0, lambda: self._do_model_upload_confirm(msg, to_upload, api))
846
+
847
+ except Exception:
848
+ self.log_exc("扫描失败", is_model=True)
849
+ self.after(0, lambda: self.status_model_var.set("扫描失败"))
850
+
851
+ threading.Thread(target=scan_worker, daemon=True).start()
852
+
853
+ def _do_model_upload_confirm(self, msg, to_upload, api):
854
+ if not messagebox.askyesno("确认上传", msg):
855
+ self.status_model_var.set("已取消")
856
+ return
857
+
858
+ def upload_worker():
859
+ try:
860
+ from huggingface_hub import CommitOperationAdd
861
+
862
+ # 🌟 开始上传,禁用按钮,重置进度条
863
+ self.after(0, lambda: self.btn_upload.configure(state="disabled"))
864
+ self.after(0, lambda: self.progress_var.set(0))
865
+ self.after(0, lambda: self.status_model_var.set("正在打包并上传..."))
866
+
867
+ # 1. 准备操作列表
868
+ operations = []
869
+ for local_file, remote_path in to_upload:
870
+ # 极度关键:防止 Windows 反斜杠导致远端多建文件夹
871
+ safe_remote_path = remote_path.replace("\\", "/")
872
+ operations.append(
873
+ CommitOperationAdd(
874
+ path_or_fileobj=str(local_file),
875
+ path_in_repo=safe_remote_path
876
+ )
877
+ )
878
+
879
+ # 2. 准备 commit message
880
+ remote_dir = self.remote_dir_var.get().strip().replace("\\", "/").strip("/")
881
+ commit_msg = f"Upload files to {remote_dir}" if remote_dir else "Upload files to root"
882
+
883
+ self.log_model(f"开始打包 {len(operations)} 个文件,一次性 Commit...")
884
+ self.log_model(f"Commit message: {commit_msg}")
885
+
886
+ # 🌟 3. 假进度条控制变量
887
+ is_uploading = [True]
888
+ def update_progress():
889
+ if not is_uploading[0]: return # 如果上传结束,停止动画
890
+ current = self.progress_var.get()
891
+ # 慢慢爬升到 95%,最后留 5% 给真正完成时冲刺
892
+ if current < 95:
893
+ self.progress_var.set(current + 2)
894
+ self.after(300, update_progress)
895
+
896
+ self.after(100, update_progress) # 启动动画
897
+
898
+ # 4. 一次性提交多个文件 (同步阻塞操作)
899
+ api.create_commit(
900
+ repo_id=MODEL_REPO_ID,
901
+ repo_type="model",
902
+ operations=operations,
903
+ commit_message=commit_msg
904
+ )
905
+
906
+ # 🌟 5. 上传成功:停止动画,进度条瞬间满格
907
+ is_uploading[0] = False
908
+ self.after(0, lambda: self.progress_var.set(100))
909
+ self.after(0, lambda: self.status_model_var.set("上传完成 ✅"))
910
+ self.after(0, lambda: self.btn_upload.configure(state="normal"))
911
+
912
+ self.log_model("打包上传成功!")
913
+ self.after(0, lambda: messagebox.showinfo("完成", "上传成功!"))
914
+ self.after(0, self.load_remote) # 自动刷新 JSON
915
+
916
+ except Exception as e:
917
+ # 🌟 失败时:停止动画,重置进度条,恢复按钮
918
+ is_uploading[0] = False
919
+ self.after(0, lambda: self.progress_var.set(0))
920
+ self.after(0, lambda: self.btn_upload.configure(state="normal"))
921
+ self.after(0, lambda: self.status_model_var.set("上传失败 ❌"))
922
+
923
+ self.log_exc("打包上传失败", is_model=True)
924
+ self.after(0, lambda: messagebox.showerror("失败", "详见日志"))
925
+
926
+ threading.Thread(target=upload_worker, daemon=True).start()
927
+
928
+ # ==========================================================
929
+ # ==================== 区域 3: Dataset 上传 =================
930
+ # ==========================================================
931
+ def _build_dataset_tab(self):
932
+ parent = self.tab_dataset
933
+
934
+ frame = ttk.Frame(parent)
935
+ frame.pack(fill="x", padx=10, pady=10)
936
+
937
+ # 1. 本地母文件夹
938
+ ttk.Label(frame, text="本地母文件夹:").grid(row=0, column=0, sticky="w", pady=4)
939
+ self.ds_local_folder_var = tk.StringVar()
940
+ ttk.Entry(frame, textvariable=self.ds_local_folder_var, width=70).grid(row=0, column=1, columnspan=2, padx=6, sticky="we")
941
+ ttk.Button(frame, text="📁 浏览...", width=18, command=self._browse_ds_local).grid(row=0, column=3)
942
+
943
+ # 2. 地区/分类前缀 (动态读取改造)
944
+ ttk.Label(frame, text="Region 前缀:").grid(row=1, column=0, sticky="w", pady=8)
945
+
946
+ prefix_frame = ttk.Frame(frame)
947
+ prefix_frame.grid(row=1, column=1, columnspan=3, sticky="w", padx=6, pady=8)
948
+
949
+ # 核心变量初始化
950
+ self.ds_region_var = tk.StringVar(value="KR")
951
+ self.ds_custom_region_var = tk.StringVar() # 必须保留这个变量用于输入框
952
+
953
+ # 按钮:刷新 Dataset 目录
954
+ ttk.Button(prefix_frame, text="🔄 拉取 datasets 目录", command=self._fetch_dataset_dirs).pack(side="left", padx=(0, 10))
955
+
956
+ # 用子 Frame 专门放单选框,刷新时只清空这里的控件
957
+ self.ds_radio_container = ttk.Frame(prefix_frame)
958
+ self.ds_radio_container.pack(side="left")
959
+
960
+ # 初始默认放几个,防止还没拉取时界面太难看
961
+ self._render_dataset_radios(["CN", "KR", "JP", "Fictional", "WE"])
962
+
963
+ # 3. 按钮区
964
+ btn_row = ttk.Frame(frame)
965
+ btn_row.grid(row=2, column=0, columnspan=4, pady=15, sticky="w")
966
+ self.btn_ds_upload = ttk.Button(btn_row, text="🢁 开始 5合1 处理并上传", command=self._start_dataset_pipeline)
967
+ self.btn_ds_upload.pack(side="left")
968
+
969
+ self.status_ds_var = tk.StringVar(value="就绪")
970
+ ttk.Label(btn_row, textvariable=self.status_ds_var).pack(side="left", padx=10)
971
+
972
+ frame.columnconfigure(1, weight=1)
973
+ frame.columnconfigure(2, weight=1)
974
+
975
+ # 4. 日志区
976
+ log_frame = ttk.LabelFrame(parent, text="Dataset 自动化流水线日志")
977
+ log_frame.pack(fill="both", expand=True, padx=10, pady=6)
978
+ self.log_dataset_text = ScrolledText(log_frame, height=15, state="disabled", wrap="word")
979
+ self.log_dataset_text.pack(fill="both", expand=True)
980
+
981
+ def _fetch_dataset_dirs(self):
982
+ if not self._ensure_token(): return
983
+ self.log_dataset("正在读取 Dataset 仓库根目录...")
984
+
985
+ def work():
986
+ try:
987
+ files = self.api.list_repo_files(repo_id=DATASET_REPO_ID, repo_type="dataset")
988
+ dirs = set()
989
+ for f in files:
990
+ parts = f.split("/")
991
+ # 过滤掉 tool 和根目录文件,只保留文件夹
992
+ if len(parts) > 1 and parts[0] not in ["tool", ".git"]:
993
+ dirs.add(parts[0])
994
+
995
+ sorted_dirs = sorted(list(dirs))
996
+
997
+ # 🌟 新增:在主线程更新 UI 和打印日志
998
+ def update_ui():
999
+ self._render_dataset_radios(sorted_dirs)
1000
+ self.log_dataset(f"✅ 远端目录刷新成功!共发现 {len(sorted_dirs)} 个分类: {', '.join(sorted_dirs)}")
1001
+
1002
+ self.after(0, update_ui)
1003
+
1004
+ except Exception as e:
1005
+ self.log_exc("读取 Dataset 目录失败", is_model=True)
1006
+ # 🌟 新增:报错时更新状态栏
1007
+ self.after(0, lambda: self.status_ds_var.set("读取目录失败"))
1008
+
1009
+ threading.Thread(target=work, daemon=True).start()
1010
+
1011
+ def _render_dataset_radios(self, dirs):
1012
+ # 清空旧的单选框和输入框
1013
+ for widget in self.ds_radio_container.winfo_children():
1014
+ widget.destroy()
1015
+
1016
+ # 保持原本选中的值,如果不存在了,默认选第一个
1017
+ current_val = self.ds_region_var.get()
1018
+ if current_val not in dirs and current_val != "Custom" and dirs:
1019
+ self.ds_region_var.set(dirs[0])
1020
+
1021
+ # 动态生成分类单选框
1022
+ for region in dirs:
1023
+ ttk.Radiobutton(self.ds_radio_container, text=region, variable=self.ds_region_var, value=region).pack(side="left", padx=(0, 10))
1024
+
1025
+ # 加上自定义部分 (注意:父级是 self.ds_radio_container)
1026
+ ttk.Radiobutton(self.ds_radio_container, text="自定义:", variable=self.ds_region_var, value="Custom").pack(side="left", padx=(10, 2))
1027
+ self.ds_custom_entry = ttk.Entry(self.ds_radio_container, textvariable=self.ds_custom_region_var, width=15, state="disabled")
1028
+ self.ds_custom_entry.pack(side="left")
1029
+
1030
+ # 🌟 重新绑定输入框激活逻辑 (因为输入框是刚刚重新创建的)
1031
+ def on_region_change(*args):
1032
+ if self.ds_region_var.get() == "Custom":
1033
+ self.ds_custom_entry.configure(state="normal")
1034
+ else:
1035
+ self.ds_custom_entry.configure(state="disabled")
1036
+
1037
+ # 清理之前的 trace,防止多次刷新后绑定重复导致卡顿或报错
1038
+ if hasattr(self, '_region_trace_id'):
1039
+ try:
1040
+ self.ds_region_var.trace_remove("write", self._region_trace_id)
1041
+ except Exception:
1042
+ pass
1043
+
1044
+ self._region_trace_id = self.ds_region_var.trace_add("write", on_region_change)
1045
+
1046
+
1047
+ def log_dataset(self, msg):
1048
+ self._log_to_tab(msg, getattr(self, 'log_dataset_text', None))
1049
+
1050
+ def _browse_ds_local(self):
1051
+ # 🌟 初始目录设置为当前脚本所在目录,方便用户找
1052
+ current_dir = os.path.dirname(os.path.abspath(__file__))
1053
+ folder = filedialog.askdirectory(title="选择包含子文件夹的数据集母目录", initialdir=current_dir)
1054
+ if folder:
1055
+ self.ds_local_folder_var.set(folder)
1056
+
1057
+ def _start_dataset_pipeline(self):
1058
+ local_dir = self.ds_local_folder_var.get().strip()
1059
+
1060
+ # ==== 修改这里:获取前缀逻辑 ====
1061
+ selected_region = self.ds_region_var.get()
1062
+ if selected_region == "Custom":
1063
+ region_prefix = self.ds_custom_region_var.get().strip().replace(" ", "_").replace("-", "_")
1064
+ if not region_prefix:
1065
+ messagebox.showwarning("错误", "您选择了自定义前缀,但输入框为空。")
1066
+ return
1067
+ else:
1068
+ region_prefix = selected_region
1069
+ # ===============================
1070
+
1071
+ if not local_dir or not os.path.exists(local_dir):
1072
+ messagebox.showwarning("错误", "请选择有效的本地母文件夹。")
1073
+ return
1074
+ if not self._ensure_token(): return
1075
+
1076
+ if not messagebox.askyesno("确认", f"即将对以下目录执行 5合1 流水线:\n{local_dir}\n前缀:{region_prefix}\n\n继续?"):
1077
+ return
1078
+
1079
+ self.btn_ds_upload.configure(state="disabled")
1080
+ self.status_ds_var.set("处理中...")
1081
+ self.log_dataset(f"================ 开始 5合1 流水线 ================")
1082
+ self.log_dataset(f"目标目录: {local_dir} | 前缀: {region_prefix}")
1083
+
1084
+ def worker():
1085
+ api = self.api
1086
+ try:
1087
+ # 1. 清理 txt 换行符
1088
+ self._clean_txt_newlines(local_dir)
1089
+
1090
+ # 2. 清理 Mask 元数据
1091
+ self._remove_mask_metadata(local_dir)
1092
+
1093
+ # 3. 转换 PNG -> JPG
1094
+ self._convert_png_to_jpg(local_dir)
1095
+
1096
+ # 4. 打包 Parquet 并更新 YAML
1097
+ temp_readme_path, valid_folders = self._generate_parquet_and_update_yaml(api, local_dir, region_prefix)
1098
+
1099
+ # 5. 上传
1100
+ self._upload_files_and_readme(api, local_dir, region_prefix, temp_readme_path, valid_folders)
1101
+
1102
+ self.log_dataset("🎉 5合1 全流程处理完毕!")
1103
+ self.after(0, lambda: messagebox.showinfo("完成", "Dataset 流水线执行成功!"))
1104
+ self.after(0, lambda: self.status_ds_var.set("上传完成"))
1105
+
1106
+ # 刷新 JSON 的拉取(可选,如果需要马上更新界面的话)
1107
+ self.after(0, self.load_remote)
1108
+
1109
+ except Exception as e:
1110
+ self.log_exc("Dataset 流水线异常", is_model=True)
1111
+ self.after(0, lambda: self.status_ds_var.set("执行失败"))
1112
+ finally:
1113
+ self.after(0, lambda: self.btn_ds_upload.configure(state="normal"))
1114
+
1115
+ threading.Thread(target=worker, daemon=True).start()
1116
+
1117
+ # ---------------- 原有脚本的函数改造 ----------------
1118
+
1119
+ def _clean_txt_newlines(self, current_dir):
1120
+ import glob
1121
+ self.log_dataset("🧹 [任务 1/5] 正在清理所有 TXT 文件中的换行符...")
1122
+ txt_files = glob.glob(os.path.join(current_dir, "**", "*.txt"), recursive=True)
1123
+ if not txt_files:
1124
+ self.log_dataset(" ✅ 没有找到需要处理的 TXT 文件。")
1125
+ return
1126
+
1127
+ cleaned_count = 0
1128
+ for txt_path in txt_files:
1129
+ if os.path.dirname(txt_path) == current_dir:
1130
+ continue
1131
+ try:
1132
+ with open(txt_path, 'r', encoding='utf-8') as f:
1133
+ content = ''.join(line.strip() for line in f)
1134
+ with open(txt_path, 'w', encoding='utf-8') as f:
1135
+ f.write(content)
1136
+ cleaned_count += 1
1137
+ except Exception as e:
1138
+ self.log_dataset(f" ⚠️ 处理 {os.path.basename(txt_path)} 失败: {e}")
1139
+ self.log_dataset(f" 🎉 TXT 清理完成!共处理了 {cleaned_count} 个文件。")
1140
+
1141
+ def _remove_mask_metadata(self, current_dir):
1142
+ import glob
1143
+ self.log_dataset("🛡️ [任务 2/5] 正在剥离 Mask 蒙版图的元数据...")
1144
+ png_files = glob.glob(os.path.join(current_dir, "**", "*.png"), recursive=True)
1145
+ mask_files = [f for f in png_files if "masklabel" in os.path.basename(f).lower()]
1146
+
1147
+ if not mask_files:
1148
+ self.log_dataset(" ✅ 没有找到 masklabel.png 文件,跳过此步骤。")
1149
+ return
1150
+
1151
+ success_count = 0
1152
+ for file_path in mask_files:
1153
+ try:
1154
+ with Image.open(file_path) as img:
1155
+ data = list(img.getdata())
1156
+ clean_img = Image.new(img.mode, img.size)
1157
+ clean_img.putdata(data)
1158
+ clean_img.save(file_path, "PNG")
1159
+ success_count += 1
1160
+ except Exception as e:
1161
+ self.log_dataset(f" ⚠️ 处理失败 [{os.path.basename(file_path)}]: {e}")
1162
+ self.log_dataset(f" 🎉 元数据清理完成!共处理了 {success_count} 张 Mask 图。")
1163
+
1164
+ def _convert_png_to_jpg(self, current_dir):
1165
+ import glob
1166
+ self.log_dataset("🎬 [任务 3/5] 正在批量转换常规 PNG 为 JPG...")
1167
+ png_files = glob.glob(os.path.join(current_dir, "**", "*.png"), recursive=True)
1168
+ if not png_files:
1169
+ self.log_dataset(" ✅ 没有找到需要转换的 PNG 文件。")
1170
+ return
1171
+
1172
+ converted_count = 0
1173
+ for png_path in png_files:
1174
+ if "masklabel" in png_path.lower():
1175
+ continue
1176
+ jpg_path = os.path.splitext(png_path)[0] + ".jpg"
1177
+ try:
1178
+ subprocess.run(
1179
+ ["ffmpeg", "-y", "-i", png_path, "-q:v", "2", jpg_path],
1180
+ stdout=subprocess.DEVNULL,
1181
+ stderr=subprocess.DEVNULL,
1182
+ check=True
1183
+ )
1184
+ os.remove(png_path)
1185
+ converted_count += 1
1186
+ self.log_dataset(f" 🔄 转换: {os.path.basename(png_path)} -> {os.path.basename(jpg_path)}")
1187
+ except subprocess.CalledProcessError:
1188
+ self.log_dataset(f" ⚠️ 转换失败: {png_path}")
1189
+ except FileNotFoundError:
1190
+ self.log_dataset(" ❌ 错误: 找不到 ffmpeg!请确保已安装并配置了环境变量。")
1191
+ return
1192
+ self.log_dataset(f" 🎉 转换完成!共转换 {converted_count} 张图片。")
1193
+
1194
+ def _generate_parquet_and_update_yaml(self, api, current_dir, region_prefix):
1195
+ import glob, tempfile, pandas as pd
1196
+ from datasets import Dataset, Features, Image as HFImage, Value
1197
+ from huggingface_hub import DatasetCard
1198
+ from PIL import Image as PILImage
1199
+
1200
+ self.log_dataset("📦 [任务 4/5] 正在本地打包 Parquet 并更新 YAML 配置...")
1201
+ local_folder_name = os.path.basename(current_dir).replace(" ", "_").replace("-", "_")
1202
+
1203
+ self.log_dataset(f" ☁️ 正在读取云端 {DATASET_REPO_ID} 的现有配置...")
1204
+ try:
1205
+ # 注意这里用 DATASET_REPO_ID 替换了原脚本的 REPO_ID
1206
+ remote_readme_path = api.hf_hub_download(repo_id=DATASET_REPO_ID, filename="README.md", repo_type="dataset")
1207
+ with open(remote_readme_path, "r", encoding="utf-8") as f:
1208
+ content = f.read()
1209
+ card = DatasetCard(content)
1210
+ self.log_dataset(" ✅ 成功读取云端 README。")
1211
+ except Exception as e:
1212
+ self.log_dataset(f" ⚠️ 找不到云端 README,将新建: {e}")
1213
+ card = DatasetCard("")
1214
+
1215
+ yaml_data = card.data if card.data else {}
1216
+ if "configs" not in yaml_data: yaml_data["configs"] = []
1217
+
1218
+ yaml_data["dataset_info"] = {
1219
+ "features": [
1220
+ {"name": "image", "dtype": "image"},
1221
+ {"name": "file_name", "dtype": "string"},
1222
+ {"name": "text", "dtype": "string"}
1223
+ ]
1224
+ }
1225
+
1226
+ existing_config_names = [c.get("config_name") for c in yaml_data["configs"]]
1227
+ new_configs_added = False
1228
+ valid_folders = []
1229
+
1230
+ for sub_folder_name in os.listdir(current_dir):
1231
+ sub_folder_path = os.path.join(current_dir, sub_folder_name)
1232
+ if os.path.isdir(sub_folder_path) and not sub_folder_name.startswith('.'):
1233
+ jpg_files = glob.glob(os.path.join(sub_folder_path, "*.jpg")) + glob.glob(os.path.join(sub_folder_path, "*.jpeg"))
1234
+ if not jpg_files:
1235
+ self.log_dataset(f" ⚠️ 跳过子文件夹 [{sub_folder_name}]: 未找到任何 .jpg 图片。")
1236
+ continue
1237
+
1238
+ self.log_dataset(f" 📦 正在打包: {sub_folder_name}")
1239
+ data_rows = []
1240
+
1241
+ for jpg_path in jpg_files:
1242
+ file_name = os.path.basename(jpg_path)
1243
+ base_name = os.path.splitext(file_name)[0]
1244
+ txt_path = os.path.join(sub_folder_path, f"{base_name}.txt")
1245
+
1246
+ text_content = ""
1247
+ if os.path.exists(txt_path):
1248
+ with open(txt_path, "r", encoding="utf-8") as f:
1249
+ text_content = f.read().strip()
1250
+
1251
+ try:
1252
+ with open(jpg_path, "rb") as f:
1253
+ image_bytes = {"bytes": f.read(), "path": None}
1254
+ except Exception as e:
1255
+ self.log_dataset(f" ⚠️ 跳过损坏图片 {file_name}: {e}")
1256
+ continue
1257
+
1258
+ data_rows.append({
1259
+ "image": image_bytes,
1260
+ "file_name": file_name,
1261
+ "text": text_content
1262
+ })
1263
+
1264
+ if data_rows:
1265
+ df = pd.DataFrame(data_rows)
1266
+ features = Features({
1267
+ "image": HFImage(),
1268
+ "file_name": Value("string"),
1269
+ "text": Value("string")
1270
+ })
1271
+ ds = Dataset.from_pandas(df, features=features)
1272
+ ds.to_parquet(os.path.join(sub_folder_path, "train.parquet"))
1273
+ valid_folders.append(sub_folder_name)
1274
+ self.log_dataset(f" ✅ 生成 train.parquet ({len(data_rows)} 条数据)")
1275
+
1276
+ config_name = f"{local_folder_name}_{sub_folder_name}".lower()
1277
+ path_str = f"{region_prefix}/{local_folder_name}/{sub_folder_name}/train.parquet"
1278
+
1279
+ if config_name not in existing_config_names:
1280
+ yaml_data["configs"].append({
1281
+ "config_name": config_name,
1282
+ "data_files": [{"split": "train", "path": path_str}]
1283
+ })
1284
+ new_configs_added = True
1285
+ self.log_dataset(f" ➕ 已添加配置: {config_name}")
1286
+
1287
+ def get_priority(config):
1288
+ data_files = config.get("data_files", [])
1289
+ path = data_files[0].get("path", "") if data_files else ""
1290
+ priority_dict = {"CN": 1, "KR": 2, "JP": 3, "Fictional": 4, "WE": 5}
1291
+ path_prefix = path.split('/')[0] if path else ""
1292
+ return priority_dict.get(path_prefix, 99)
1293
+
1294
+ yaml_data["configs"] = sorted(yaml_data["configs"], key=lambda c: (get_priority(c), c.get("config_name", "")))
1295
+ self.log_dataset(" 🔄 已根据 [CN > KR > JP > Fictional > WE] 的顺序重排 YAML。")
1296
+
1297
+ if new_configs_added:
1298
+ card.data = yaml_data
1299
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.md', delete=False) as temp_readme:
1300
+ temp_readme.write(str(card))
1301
+ return temp_readme.name, valid_folders
1302
+ return None, valid_folders
1303
+
1304
+ def _upload_files_and_readme(self, api, current_dir, region_prefix, temp_readme_path, valid_folders):
1305
+ self.log_dataset("🚀 [任务 5/5] 正在上传文件到 Hugging Face...")
1306
+ local_folder_name = os.path.basename(current_dir).replace(" ", "_").replace("-", "_")
1307
+
1308
+ for sub_folder_name in valid_folders:
1309
+ sub_folder_path = os.path.join(current_dir, sub_folder_name)
1310
+ path_in_repo = f"{region_prefix}/{local_folder_name}/{sub_folder_name}"
1311
+
1312
+ self.log_dataset(f" 📦 开始上传: {sub_folder_name} -> {path_in_repo}")
1313
+ try:
1314
+ api.upload_folder(
1315
+ folder_path=sub_folder_path,
1316
+ repo_id=DATASET_REPO_ID,
1317
+ repo_type="dataset",
1318
+ path_in_repo=path_in_repo,
1319
+ commit_message=f"Update files for {path_in_repo}",
1320
+ allow_patterns=["*.jpg", "*.jpeg", "*.txt", "*.png", "*.parquet"]
1321
+ )
1322
+ self.log_dataset(f" 🎉 {sub_folder_name} 上传完成!")
1323
+ except Exception as e:
1324
+ self.log_dataset(f" ❌ {sub_folder_name} 上传失败: {e}")
1325
+
1326
+ if temp_readme_path:
1327
+ self.log_dataset("\n 📝 正在推送最新的 README.md...")
1328
+ try:
1329
+ api.upload_file(
1330
+ path_or_fileobj=temp_readme_path,
1331
+ path_in_repo="README.md",
1332
+ repo_id=DATASET_REPO_ID,
1333
+ repo_type="dataset",
1334
+ commit_message="Auto-update README YAML configs"
1335
+ )
1336
+ self.log_dataset(" 🎉 README.md 已成功更新到云端!")
1337
+ except Exception as e:
1338
+ self.log_dataset(f" ❌ README.md 上传失败: {e}")
1339
+ finally:
1340
+ os.remove(temp_readme_path)
1341
+ # ==========================================================
1342
+ # ==================== 区域 4: Showcase 管理 ================
1343
+ # ==========================================================
1344
+ def _build_showcase_tab(self):
1345
+ parent = self.tab_showcase
1346
+
1347
+ # 1. 输入区
1348
+ frame = ttk.Frame(parent)
1349
+ frame.pack(fill="x", padx=10, pady=10)
1350
+
1351
+ # 本地文件夹
1352
+ ttk.Label(frame, text="本地文件夹:").grid(row=0, column=0, sticky="w", pady=4)
1353
+ self.sc_local_folder_var = tk.StringVar()
1354
+ ttk.Entry(frame, textvariable=self.sc_local_folder_var, width=70).grid(row=0, column=1, padx=6, sticky="we")
1355
+ ttk.Button(frame, text="📁 浏览...", width=18, command=self._browse_sc_local).grid(row=0, column=2)
1356
+
1357
+ # 远端目录 (Treeview 改造,完全复用 Models 的样式)
1358
+ ttk.Label(frame, text="上传到 Showcase 目录:").grid(row=1, column=0, sticky="nw", pady=4)
1359
+
1360
+ tree_frame = ttk.Frame(frame)
1361
+ tree_frame.grid(row=1, column=1, padx=6, sticky="nsew")
1362
+
1363
+ # 滚动条
1364
+ vsb = ttk.Scrollbar(tree_frame, orient="vertical")
1365
+ vsb.pack(side="right", fill="y")
1366
+
1367
+ # 树形列表
1368
+ self.sc_remote_tree = ttk.Treeview(tree_frame, height=6, show="tree",yscrollcommand=vsb.set, selectmode="browse")
1369
+ self.sc_remote_tree.pack(side="left", fill="both", expand=True)
1370
+ vsb.config(command=self.sc_remote_tree.yview)
1371
+
1372
+ # 绑定树形点击事件
1373
+ self.sc_remote_tree.bind("<<TreeviewSelect>>", self._on_sc_remote_tree_select)
1374
+
1375
+ # 刷新按钮
1376
+ ttk.Button(frame, text="🔄 拉取案例目录", width=18, command=self._fetch_showcase_dirs).grid(row=1, column=2, sticky="n")
1377
+
1378
+ # 按钮区
1379
+ btn_row = ttk.Frame(frame)
1380
+ btn_row.grid(row=2, column=0, columnspan=3, pady=15, sticky="w")
1381
+
1382
+ self.btn_sc_upload = ttk.Button(btn_row, text="🢁 生成 JSON 并上传", command=self._start_showcase_pipeline)
1383
+ self.btn_sc_upload.pack(side="left")
1384
+
1385
+ self.status_sc_var = tk.StringVar(value="就绪")
1386
+ ttk.Label(btn_row, textvariable=self.status_sc_var).pack(side="left", padx=10)
1387
+
1388
+ # 🌟 新增:Showcase 链接展示区
1389
+ result_row = ttk.Frame(frame)
1390
+ result_row.grid(row=3, column=0, columnspan=4, pady=(0, 10), sticky="we")
1391
+
1392
+ ttk.Label(result_row, text="Showcase 链接:").pack(side="left")
1393
+
1394
+ self.sc_result_var = tk.StringVar()
1395
+ self.sc_result_entry = ttk.Entry(result_row, textvariable=self.sc_result_var, state="readonly")
1396
+ self.sc_result_entry.pack(side="left", fill="x", expand=True, padx=6)
1397
+
1398
+ # 复制按钮
1399
+ ttk.Button(result_row, text="📋 复制", command=self._copy_sc_url).pack(side="left")
1400
+
1401
+ frame.columnconfigure(1, weight=1)
1402
+ frame.rowconfigure(1, weight=1)
1403
+
1404
+ frame.columnconfigure(1, weight=1)
1405
+ frame.rowconfigure(1, weight=1)
1406
+
1407
+ # 2. 日志区
1408
+ log_frame = ttk.LabelFrame(parent, text="Showcase 操作日志")
1409
+ log_frame.pack(fill="both", expand=True, padx=10, pady=6)
1410
+ self.log_sc_text = ScrolledText(log_frame, height=10, state="disabled", wrap="word")
1411
+ self.log_sc_text.pack(fill="both", expand=True)
1412
+
1413
+ def _copy_sc_url(self):
1414
+ url = self.sc_result_var.get()
1415
+ if url:
1416
+ self.clipboard_clear()
1417
+ self.clipboard_append(url)
1418
+ messagebox.showinfo("成功", "链接已复制到剪贴板!")
1419
+
1420
+ def log_showcase(self, msg):
1421
+ self._log_to_tab(msg, getattr(self, 'log_sc_text', None))
1422
+
1423
+ def _browse_sc_local(self):
1424
+ if getattr(sys, 'frozen', False):
1425
+ current_dir = os.path.dirname(sys.executable)
1426
+ else:
1427
+ current_dir = os.path.dirname(os.path.abspath(__file__))
1428
+ folder = filedialog.askdirectory(title="选择包含 PNG 的 Showcase 文件夹", initialdir=current_dir)
1429
+ if folder:
1430
+ self.sc_local_folder_var.set(folder)
1431
+
1432
+ def _fetch_showcase_dirs(self):
1433
+ if not self._ensure_token(): return
1434
+ self.status_sc_var.set("正在读取远端目录...")
1435
+ self.log_showcase("正在读取 Showcase 仓库目录列表...")
1436
+
1437
+ def work():
1438
+ try:
1439
+ api = self.api
1440
+ all_files = list(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
1441
+ dirs = set()
1442
+ for f in all_files:
1443
+ # 核心修改:只提取 comfyui_showcase 下的文件夹
1444
+ if f.startswith("comfyui_showcase/"):
1445
+ d = str(Path(f).parent)
1446
+ if d != "comfyui_showcase":
1447
+ dirs.add(d)
1448
+
1449
+ sorted_dirs = sorted(list(dirs))
1450
+ self.after(0, lambda: self._update_sc_remote_tree(sorted_dirs))
1451
+ except Exception:
1452
+ self.log_exc("读取 Showcase 目录失败", is_model=True)
1453
+ finally:
1454
+ self.after(0, lambda: self.status_sc_var.set("就绪"))
1455
+
1456
+ threading.Thread(target=work, daemon=True).start()
1457
+
1458
+ def _update_sc_remote_tree(self, dirs):
1459
+ for item in self.sc_remote_tree.get_children():
1460
+ self.sc_remote_tree.delete(item)
1461
+
1462
+ tree_nodes = {}
1463
+ for d in dirs:
1464
+ safe_d = d.replace("\\", "/")
1465
+ parts = safe_d.split("/")
1466
+ parent_id = ""
1467
+ current_path = ""
1468
+ for i, part in enumerate(parts):
1469
+ current_path = f"{current_path}/{part}" if current_path else part
1470
+ if current_path not in tree_nodes:
1471
+ is_leaf = (i == len(parts) - 1)
1472
+ display_text = f"📁 {part}" if is_leaf else part
1473
+ node_id = self.sc_remote_tree.insert(parent_id, "end", text=display_text, values=(current_path,))
1474
+ tree_nodes[current_path] = node_id
1475
+ parent_id = tree_nodes[current_path]
1476
+
1477
+ for root_node in self.sc_remote_tree.get_children(""):
1478
+ self.sc_remote_tree.item(root_node, open=True)
1479
+ self.log_showcase(f"已加载 {len(dirs)} 个远端目录结构")
1480
+
1481
+ def _on_sc_remote_tree_select(self, event):
1482
+ selection = self.sc_remote_tree.selection()
1483
+ if selection:
1484
+ item_id = selection[0]
1485
+ values = self.sc_remote_tree.item(item_id, "values")
1486
+ if values:
1487
+ self.sc_remote_dir = values[0]
1488
+
1489
+ def _start_showcase_pipeline(self):
1490
+ local_dir = self.sc_local_folder_var.get().strip()
1491
+ remote_dir = getattr(self, 'sc_remote_dir', "").strip().strip("/")
1492
+
1493
+ if not local_dir or not os.path.exists(local_dir):
1494
+ messagebox.showwarning("错误", "请选择有效的本地文件夹。")
1495
+ return
1496
+ if not remote_dir:
1497
+ messagebox.showwarning("错误", "请先在右侧选择要上传到的远端目录。")
1498
+ return
1499
+ if not self._ensure_token(): return
1500
+
1501
+ # 提取本地文件夹名字,准备拼接到远端路径
1502
+ local_folder_name = os.path.basename(os.path.normpath(local_dir))
1503
+ final_target_path = f"{remote_dir}/{local_folder_name}"
1504
+
1505
+ if not messagebox.askyesno("确认", f"即将增量上传并更新 JSON:\n{MODEL_REPO_ID}/{final_target_path}\n\n继续?"):
1506
+ return
1507
+
1508
+ self.btn_sc_upload.configure(state="disabled")
1509
+ self.status_sc_var.set("处理中...")
1510
+ self.log_showcase("================ 开始 Showcase 增量流水线 ================")
1511
+ self.log_showcase(f"本地: {local_dir} -> 远端: {final_target_path}")
1512
+
1513
+ def worker():
1514
+ api = self.api
1515
+ try:
1516
+ # 1. 尝试拉取云端旧的 gallery.json
1517
+ remote_json_path = f"{final_target_path}/gallery.json"
1518
+ existing_data = []
1519
+ self.log_showcase(f"☁️ 正在检查云端是否已存在 gallery.json...")
1520
+
1521
+ try:
1522
+ local_tmp_json = api.hf_hub_download(
1523
+ repo_id=MODEL_REPO_ID,
1524
+ repo_type="model",
1525
+ filename=remote_json_path
1526
+ )
1527
+ with open(local_tmp_json, "r", encoding="utf-8") as f:
1528
+ existing_data = json.load(f)
1529
+ self.log_showcase(f" ✅ 发现云端旧数据,包含 {len(existing_data)} 条记录。")
1530
+ except Exception:
1531
+ self.log_showcase(" ℹ️ 云端暂无旧 JSON,将生成全新 JSON。")
1532
+ existing_data = []
1533
+
1534
+ # 2. 提取本地新图片的数据
1535
+ png_files = list(Path(local_dir).glob("*.png"))
1536
+ if not png_files:
1537
+ self.log_showcase("❌ 本地文件夹中没有找到 .png 图片。")
1538
+ self.after(0, lambda: self.status_sc_var.set("未找到图片"))
1539
+ return
1540
+
1541
+ self.log_showcase(f"📦 发现 {len(png_files)} 张本地 PNG,开始提取数据...")
1542
+ new_gallery_data = []
1543
+ for png in png_files:
1544
+ data = self._extract_single_png(png)
1545
+ if data:
1546
+ new_gallery_data.append(data)
1547
+ self.log_showcase(f" ✅ 解析: {png.name}")
1548
+
1549
+ # 3. 增量合并 (由于名字不同,这里直接追加)
1550
+ # 如果你想防止重复上传完全一样的图,可以用 file_name 去重,这里我们用字典去重然后转回列表
1551
+ merged_dict = {item["file_name"]: item for item in existing_data if "file_name" in item}
1552
+ for item in new_gallery_data:
1553
+ merged_dict[item["file_name"]] = item # 如果本地同名,直接覆盖老的;名字不同则追加
1554
+
1555
+ final_gallery_data = list(merged_dict.values())
1556
+
1557
+ json_path = os.path.join(local_dir, "gallery.json")
1558
+ with open(json_path, "w", encoding="utf-8") as f:
1559
+ json.dump(final_gallery_data, f, indent=2, ensure_ascii=False)
1560
+ self.log_showcase(f"📄 合并后的 gallery.json 已生成,共 {len(final_gallery_data)} 条数据。")
1561
+
1562
+ # 4. 上传(只传新的图片和合并后的 JSON,云端原有的图保持不变)
1563
+ self.log_showcase(f"🚀 开始上传新的图片和 JSON...")
1564
+ api.upload_folder(
1565
+ folder_path=local_dir,
1566
+ repo_id=MODEL_REPO_ID,
1567
+ repo_type="model",
1568
+ path_in_repo=final_target_path,
1569
+ commit_message=f"Add new images and update gallery.json at {final_target_path}",
1570
+ allow_patterns=["*.png", "*.json"] # 没有 delete_patterns,所以云端旧图绝不会被删!
1571
+ )
1572
+
1573
+ self.log_showcase("🎉 Showcase 增量上传完毕!")
1574
+ self.after(0, lambda: messagebox.showinfo("完成", "Showcase 增量更新成功!"))
1575
+ self.after(0, lambda: self.status_sc_var.set("上传完成"))
1576
+
1577
+ showcase_url = f"https://huggingface.co/waveshaker/loras/tree/main/{final_target_path}"
1578
+ self.log_showcase("-" * 50)
1579
+ self.log_showcase(f"🔗 Showcase 页面: {showcase_url}")
1580
+ self.log_showcase("-" * 50)
1581
+
1582
+ self.after(0, lambda: self.sc_result_var.set(showcase_url))
1583
+
1584
+ except Exception as e:
1585
+ self.log_exc("Showcase 流水线异常", is_model=True)
1586
+ self.after(0, lambda: self.status_sc_var.set("执行失败"))
1587
+ finally:
1588
+ self.after(0, lambda: self.btn_sc_upload.configure(state="normal"))
1589
+
1590
+ threading.Thread(target=worker, daemon=True).start()
1591
+
1592
+ # ==========================================================
1593
+ # ==================== Showcase 提取核心逻辑 ================
1594
+ # ==========================================================
1595
+ def _clean_showcase_name(self, filepath):
1596
+ if not filepath or not isinstance(filepath, str): return "-"
1597
+ filename = filepath.replace("\\", "/").split("/")[-1]
1598
+ return filename.replace(".safetensors", "").replace(".ckpt", "")
1599
+
1600
+ def _find_upstream_model(self, prompt_data, input_link, depth=0):
1601
+ if depth > 15: return "-"
1602
+ if not isinstance(input_link, list) or len(input_link) != 2: return "-"
1603
+ node = prompt_data.get(str(input_link[0]))
1604
+ if not node: return "-"
1605
+
1606
+ # 🌟 核心新增:如果节点被静音/禁用,它的连线被短路,直接跳过它继续找上游
1607
+ if node.get("mode") == 4:
1608
+ # 如果是 Bypass,输入口可能叫 model,继续往上找
1609
+ bypassed_input = node.get("inputs", {}).get("model")
1610
+ if bypassed_input:
1611
+ return self._find_upstream_model(prompt_data, bypassed_input, depth+1)
1612
+ return "-"
1613
+
1614
+ class_type = node.get("class_type", "")
1615
+ inputs = node.get("inputs", {})
1616
+ if class_type in ["CheckpointLoaderSimple", "UNETLoader"]:
1617
+ return self._clean_showcase_name(inputs.get("ckpt_name") or inputs.get("unet_name"))
1618
+ elif class_type in ["LoraLoader", "LoraLoaderModelOnly"]:
1619
+ return self._find_upstream_model(prompt_data, inputs.get("model"), depth+1)
1620
+ elif inputs.get("model"):
1621
+ return self._find_upstream_model(prompt_data, inputs.get("model"), depth+1)
1622
+ return "-"
1623
+
1624
+ def _find_specific_loader(self, prompt_data, target_class_types):
1625
+ for node in prompt_data.values():
1626
+ if node.get("class_type") in target_class_types:
1627
+ inputs = node.get("inputs", {})
1628
+ return self._clean_showcase_name(inputs.get("vae_name") or inputs.get("clip_name"))
1629
+ return "-"
1630
+
1631
+ def _extract_single_png(self, png_path):
1632
+ try:
1633
+ with Image.open(png_path) as img:
1634
+ raw_prompt = img.info.get('prompt', '')
1635
+ dasiwa_text = img.info.get('parameters', '') or img.info.get('Description', '')
1636
+
1637
+ report = {"size": "-", "loras": [], "model": "-", "vae": "-", "clip": "-","sampler": "-", "scheduler": "-", "seed": "-", "steps": "-", "cfg": "-", "denoise": "-", "pos": ""}
1638
+
1639
+ if raw_prompt and raw_prompt.strip().startswith("{"):
1640
+ prompt_data = json.loads(raw_prompt)
1641
+ for node in prompt_data.values():
1642
+ # 🌟 核心新增:跳过所有被禁用的节点
1643
+ if node.get("mode") == 4:
1644
+ continue
1645
+
1646
+ if node.get("class_type") in ["KSampler", "KSamplerAdvanced"]:
1647
+ inputs = node.get("inputs", {})
1648
+ report["sampler"] = inputs.get("sampler_name", "-")
1649
+ report["scheduler"] = inputs.get("scheduler", "-")
1650
+ report["steps"] = inputs.get("steps", "-")
1651
+ report["seed"] = inputs.get("seed", "-")
1652
+ report["cfg"] = inputs.get("cfg", 1.0)
1653
+ report["denoise"] = inputs.get("denoise", 1.0)
1654
+ report["model"] = self._find_upstream_model(prompt_data, inputs.get("model"))
1655
+ break
1656
+ report["vae"] = self._find_specific_loader(prompt_data, ["VAELoader"])
1657
+ report["clip"] = self._find_specific_loader(prompt_data, ["CLIPLoader", "DualCLIPLoader"])
1658
+
1659
+ neg_text = ""
1660
+ if dasiwa_text:
1661
+ pos_match = re.search(r"^(.*?)(?:Negative prompt:|$)", dasiwa_text, re.DOTALL | re.IGNORECASE)
1662
+ if pos_match: report["pos"] = pos_match.group(1).strip()
1663
+ neg_match = re.search(r"Negative prompt:\s*(.*?)(?:Steps:|$)", dasiwa_text, re.DOTALL | re.IGNORECASE)
1664
+ if neg_match: neg_text = neg_match.group(1).strip()
1665
+ size_match = re.search(r"Size:\s*(\d+x\d+)", dasiwa_text)
1666
+ if size_match: report["size"] = size_match.group(1)
1667
+ lora_matches = re.findall(r"<lora:([^:>]+)(?::[^>]+)?>", report["pos"])
1668
+ report["loras"] = [self._clean_showcase_name(l) for l in lora_matches]
1669
+
1670
+ if neg_text and "negative_prompt_not_found" not in neg_text:
1671
+ report["neg"] = neg_text
1672
+
1673
+ report["file_name"] = os.path.basename(png_path)
1674
+ return report
1675
+ except Exception as e:
1676
+ self.log_showcase(f" ⚠️ 提取失败 {os.path.basename(png_path)}: {e}")
1677
+ return None
1678
+
1679
+ if __name__ == "__main__":
1680
+ app = ModelsManager()
1681
+ app.mainloop()