File size: 22,895 Bytes
e918930 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 | import os
import sys
import threading
import time
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
import tkinter as tk
from tkinter import filedialog, messagebox, ttk, simpledialog
from huggingface_hub import HfApi
# 开启 hf_transfer 加速
os.environ["HF_XET_HIGH_PERFORMANCE"] = "1"
# 禁用底层 CLI 的多线程进度条,防止刷屏
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
class TextHandler(logging.Handler):
def __init__(self, text_widget):
super().__init__()
self.text_widget = text_widget
def emit(self, record):
msg = self.format(record)
def append():
self.text_widget.configure(state='normal')
self.text_widget.insert(tk.END, msg + '\n')
self.text_widget.configure(state='disabled')
self.text_widget.see(tk.END)
self.text_widget.after(0, append)
class HFUploaderApp:
def __init__(self, root):
self.root = root
self.root.title("HF 仓库管理 - 大模型批量上传版")
self.root.geometry("950x700")
self.token_var = tk.StringVar()
self.repo_id_var = tk.StringVar(value="waveshaker/comfy_models")
self.selected_tree_path = ""
self.upload_queue = {}
# 控制停止的全局标志位
self.is_uploading = False
self.setup_ui()
self.load_token()
def setup_ui(self):
# 1. 顶部 Token 区
top_frame = tk.Frame(self.root, pady=10)
top_frame.pack(fill=tk.X, padx=10)
tk.Label(top_frame, text="HF Token:").pack(side=tk.LEFT)
tk.Entry(top_frame, textvariable=self.token_var, show="*", width=50).pack(side=tk.LEFT, padx=5)
tk.Button(top_frame, text="保存 Token", command=self.save_token).pack(side=tk.LEFT)
# 2. 仓库配置区
repo_frame = tk.Frame(self.root)
repo_frame.pack(fill=tk.X, padx=10, pady=5)
tk.Label(repo_frame, text="仓库 ID:").pack(side=tk.LEFT)
tk.Entry(repo_frame, textvariable=self.repo_id_var, width=30).pack(side=tk.LEFT, padx=5)
# 3. 中间分割(左侧:队列,右侧:远端树)
paned = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5)
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# --- 左侧:上传队列 ---
left_frame = tk.Frame(paned)
paned.add(left_frame, width=550)
tk.Label(left_frame, text="上传队列 (双击目标路径可修改):").pack(anchor=tk.W)
# 🌟 修正:列名统一为 source
cols = ("source", "remote", "status")
self.queue_tree = ttk.Treeview(left_frame, columns=cols, show="headings", height=10)
self.queue_tree.heading("source", text="文件源 (本地/远端链接)")
self.queue_tree.heading("remote", text="远端目标目录")
self.queue_tree.heading("status", text="状态")
self.queue_tree.column("source", width=250)
self.queue_tree.column("remote", width=150)
self.queue_tree.column("status", width=80, anchor="center")
self.queue_tree.tag_configure("done", foreground="gray")
self.queue_tree.pack(fill=tk.BOTH, expand=True)
self.queue_tree.bind("<Double-1>", self.on_edit_remote_path)
queue_btns = tk.Frame(left_frame)
queue_btns.pack(fill=tk.X, pady=5)
tk.Button(queue_btns, text="➕ 添加本地文件", command=self.add_to_queue).pack(side=tk.LEFT)
tk.Button(queue_btns, text="❌ 移除选中", command=self.remove_from_queue).pack(side=tk.LEFT, padx=5)
# 🌟 极简的链接输入框
self.remote_url_var = tk.StringVar()
tk.Entry(queue_btns, textvariable=self.remote_url_var, width=30).pack(side=tk.LEFT, padx=5)
tk.Button(queue_btns, text="🔗 加入队列", command=self.add_remote_to_queue, ).pack(side=tk.LEFT)
right_frame = tk.Frame(paned)
paned.add(right_frame, width=350)
tk.Label(right_frame, text="远端仓库目录树:").pack(anchor=tk.W)
self.tree_view = ttk.Treeview(right_frame, show="tree", height=10)
self.tree_view.pack(fill=tk.BOTH, expand=True)
self.tree_view.bind("<<TreeviewSelect>>", self.on_tree_select)
tree_btns = tk.Frame(right_frame)
tree_btns.pack(fill=tk.X, pady=5)
tk.Button(tree_btns, text="🔄 拉取远端目录", command=self.fetch_remote_tree).pack(side=tk.LEFT)
tk.Button(tree_btns, text="📁 新建文件夹", command=self.create_remote_folder).pack(side=tk.LEFT, padx=5)
tk.Button(tree_btns, text="⬇️ 设为目标", command=self.set_target_from_tree).pack(side=tk.LEFT)
# 4. 操作区 (包含开始和停止)
action_frame = tk.Frame(self.root, pady=10)
action_frame.pack(fill=tk.X, padx=10)
self.upload_btn = tk.Button(action_frame, text="🚀 开始并发上传", command=self.start_upload, font=("Arial", 10, "bold"))
self.upload_btn.pack(side=tk.LEFT, ipadx=20, ipady=5)
# 停止按钮
self.stop_btn = tk.Button(action_frame, text="🛑 停止上传", command=self.stop_upload, font=("Arial", 10, "bold"), state=tk.DISABLED)
self.stop_btn.pack(side=tk.LEFT, padx=10, ipadx=20, ipady=5)
# 🌟 GUI 内部总进度条
self.total_progress_var = tk.DoubleVar()
self.progress_bar = ttk.Progressbar(action_frame, orient="horizontal", length=200, mode="determinate", variable=self.total_progress_var)
self.progress_bar.pack(side=tk.LEFT, padx=15, fill=tk.X, expand=True)
# 进度文字标签
self.progress_text = tk.Label(action_frame, text="等待上传...", fg="#2196F3", font=("Arial", 10, "bold"))
self.progress_text.pack(side=tk.LEFT, padx=5)
# 5. 日志区
log_frame = tk.Frame(self.root, padx=10, pady=5)
log_frame.pack(fill=tk.BOTH, expand=True)
tk.Label(log_frame, text="操作日志:").pack(anchor=tk.W)
self.log_text = tk.Text(log_frame, height=12, state='disabled', bg="#f0f0f0")
self.log_text.pack(fill=tk.BOTH, expand=True)
self.logger = logging.getLogger("HF_Uploader")
self.logger.setLevel(logging.INFO)
handler = TextHandler(self.log_text)
handler.setFormatter(logging.Formatter('[%(asctime)s] %(message)s', '%H:%M:%S'))
self.logger.addHandler(handler)
def load_token(self):
if os.path.exists("hf_token.txt"):
with open("hf_token.txt", "r") as f:
self.token_var.set(f.read().strip())
def save_token(self):
with open("hf_token.txt", "w") as f:
f.write(self.token_var.get().strip())
messagebox.showinfo("成功", "Token 已保存到 hf_token.txt")
def add_to_queue(self):
files = filedialog.askopenfilenames(
title="选择要上传的模型文件",
filetypes=[("SafeTensors Model", "*.safetensors"), ("All Files", "*.*")]
)
if files:
for file_path in files:
if file_path not in self.upload_queue:
self.upload_queue[file_path] = ""
self.refresh_queue()
def remove_from_queue(self):
selected = self.queue_tree.selection()
for item in selected:
local_path = self.queue_tree.item(item, "values")[0]
if local_path in self.upload_queue:
del self.upload_queue[local_path]
self.refresh_queue()
def refresh_queue(self):
# 🌟 在删除之前,先把旧的“已完成”状态存下来
existing_status = {}
for item in self.queue_tree.get_children():
values = self.queue_tree.item(item, "values")
if len(values) >= 3 and values[2] == "已完成":
existing_status[values[0]] = "已完成"
# 清空现有表格
for item in self.queue_tree.get_children():
self.queue_tree.delete(item)
# 重新插入所有数据
for local_path, remote_target in self.upload_queue.items():
# 如果之前传完了,状态保持“已完成”,否则是“待上传”
status = existing_status.get(local_path, "待上传")
if status == "已完成":
self.queue_tree.insert("", tk.END, values=(local_path, remote_target, status), tags=("done",))
else:
self.queue_tree.insert("", tk.END, values=(local_path, remote_target, status))
# 自动选中最后一个
children = self.queue_tree.get_children()
if children:
last_item = children[-1]
self.queue_tree.selection_set(last_item)
self.queue_tree.focus(last_item)
self.queue_tree.see(last_item)
def on_edit_remote_path(self, event):
item = self.queue_tree.selection()
if not item: return
item = item[0]
current_local = self.queue_tree.item(item, "values")[0]
current_remote = self.queue_tree.item(item, "values")[1]
top = tk.Toplevel(self.root)
top.title("修改目标路径")
tk.Label(top, text="输入远端目标文件夹 (例如: moss/ 或留空):").pack(padx=10, pady=5)
entry = tk.Entry(top, width=40)
entry.insert(0, current_remote)
entry.pack(padx=10, pady=5)
def save():
new_path = entry.get().strip().strip("/")
self.upload_queue[current_local] = new_path
self.refresh_queue()
top.destroy()
tk.Button(top, text="确定", command=save).pack(pady=10)
def fetch_remote_tree(self):
token = self.token_var.get().strip()
repo_id = self.repo_id_var.get().strip()
if not token or not repo_id: return
self.logger.info("正在拉取远端目录结构...")
self.tree_view.delete(*self.tree_view.get_children())
def fetch():
try:
api = HfApi(token=token)
repo_files = api.list_repo_files(repo_id=repo_id, repo_type="model")
tree_dict = {}
for file_path in repo_files:
parts = file_path.split('/')
current = tree_dict
for part in parts:
if part not in current: current[part] = {}
current = current[part]
def insert_tree(parent, dict_data, current_path=""):
for key, children in dict_data.items():
path = f"{current_path}/{key}" if current_path else key
node_text = f"📄 {key}" if not children else f"📁 {key}"
node = self.tree_view.insert(parent, tk.END, text=node_text, values=(path,))
if children: insert_tree(node, children, path)
self.root.after(0, lambda: insert_tree("", tree_dict))
self.logger.info(f"成功加载远端目录结构。")
except Exception as e:
self.root.after(0, lambda err=e: self.logger.error(f"拉取失败: {err}"))
threading.Thread(target=fetch, daemon=True).start()
def create_remote_folder(self):
token = self.token_var.get().strip()
repo_id = self.repo_id_var.get().strip()
if not token or not repo_id: return
parent_path = self.selected_tree_path if self.selected_tree_path else ""
folder_name = simpledialog.askstring("新建文件夹", f"在 '{parent_path or '根目录'}' 下新建文件夹:")
if not folder_name: return
folder_name = folder_name.strip().strip("/")
new_folder_path = f"{parent_path}/{folder_name}" if parent_path else folder_name
gitkeep_path = f"{new_folder_path}/.gitkeep"
self.logger.info(f"正在新建远端文件夹: {new_folder_path} ...")
def create():
try:
api = HfApi(token=token)
api.upload_file(path_or_fileobj=b"", path_in_repo=gitkeep_path, repo_id=repo_id, repo_type="model")
self.root.after(0, lambda: self.logger.info(f"✅ 文件夹新建成功: {new_folder_path}"))
self.root.after(0, self.fetch_remote_tree)
except Exception as e:
self.root.after(0, lambda err=e: self.logger.error(f"❌ 新建失败: {err}"))
threading.Thread(target=create, daemon=True).start()
def on_tree_select(self, event):
selected = self.tree_view.selection()
if selected:
self.selected_tree_path = self.tree_view.item(selected[0], "values")[0]
if "." in os.path.basename(self.selected_tree_path):
self.selected_tree_path = os.path.dirname(self.selected_tree_path)
def set_target_from_tree(self):
if not self.selected_tree_path:
messagebox.showwarning("提示", "请先在右侧树中选中一个文件夹")
return
queue_selected = self.queue_tree.selection()
if not queue_selected:
messagebox.showwarning("提示", "请先在左侧队列中选中任务")
return
target_path = self.selected_tree_path.strip("/")
count = 0
for item in queue_selected:
values = self.queue_tree.item(item, "values")
# 🌟 如果已经是“已完成”,就不改它的路径了
if len(values) >= 3 and values[2] == "已完成":
continue
local_path = values[0]
self.upload_queue[local_path] = target_path
count += 1
self.refresh_queue()
self.logger.info(f"✅ 已将 {count} 个文件的目标路径批量设置为: {target_path}")
# === 上传与停止逻辑 ===
def start_upload(self):
token = self.token_var.get().strip()
repo_id = self.repo_id_var.get().strip()
# 🌟 核心修复:先检查有哪些文件是“待上传”的
pending_tasks = []
for tree_item in self.queue_tree.get_children():
values = self.queue_tree.item(tree_item, "values")
status = values[2] if len(values) >= 3 else "待上传"
if status != "已完成":
pending_tasks.append(tree_item)
if not token or not repo_id or not pending_tasks:
messagebox.showwarning("警告", "请检查 Token、仓库 ID,或者当前没有待上传的新文件")
return
self.is_uploading = True
self.upload_btn.config(state=tk.DISABLED, text="上传中...")
self.stop_btn.config(state=tk.NORMAL)
# 计算要上传的真实数量
total_files = len(pending_tasks)
self.logger.info(f"🚀 开始并发上传 {total_files} 个待处理文件...")
def upload_worker():
api = HfApi(token=token)
tasks = []
for tree_item in pending_tasks:
values = self.queue_tree.item(tree_item, "values")
source = values[0]
remote_dir = values[1]
# 区分是 HTTP 链接还是本地文件
if source.startswith("http://") or source.startswith("https://"):
# 这是一个远端链接,提取文件名
filename = source.split("/")[-1]
repo_path = f"{remote_dir}/{filename}" if remote_dir else filename
tasks.append((tree_item, source, repo_path, True)) # True 代表是远端
else:
# 这是一个本地文件
filename = os.path.basename(source)
repo_path = f"{remote_dir}/{filename}" if remote_dir else filename
tasks.append((tree_item, source, repo_path, False)) # False 代表是本地
completed_files = 0
progress_lock = threading.Lock()
def update_ui_progress(completed, total):
percent = (completed / total) * 100
self.root.after(0, lambda: self.total_progress_var.set(percent))
self.root.after(0, lambda: self.progress_text.config(text=f"{completed} / {total} 个文件 ({percent:.1f}%)"))
def upload_one(tree_item, source, repo_path, is_remote):
nonlocal completed_files
if not self.is_uploading:
return False, "用户已取消"
try:
if is_remote:
import subprocess
import os
clean_path = source.replace("https://huggingface.co/", "")
parts = clean_path.split("/")
if len(parts) >= 5 and parts[2] in ["blob", "resolve"]:
src_repo_id = f"{parts[0]}/{parts[1]}"
src_file_path = "/".join(parts[4:]) # 这里已经修复过了
# 读取 GUI 里的 token
current_token = self.token_var.get().strip()
cmd = [
"hf", "cp",
f"hf://{src_repo_id}/{src_file_path}",
f"hf://{repo_id}/{repo_path}"
]
# 🌟 强行把 Token 塞给子进程的环境变量,防止它去读错误的系统缓存
env = os.environ.copy()
env["HF_TOKEN"] = current_token
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
else:
# 🌟 本地文件上传逻辑
api.upload_file(
path_or_fileobj=source,
path_in_repo=repo_path,
repo_id=repo_id,
repo_type="model"
)
with progress_lock:
completed_files += 1
update_ui_progress(completed_files, total_files)
self.root.after(0, lambda: self.mark_item_done(tree_item))
return True, repo_path
except Exception as e:
return False, f"{repo_path} ({e})"
self.root.after(0, lambda: self.total_progress_var.set(0))
self.root.after(0, lambda: self.progress_text.config(text=f"0 / {total_files} 个文件", fg="#2196F3"))
with ThreadPoolExecutor(max_workers=4) as executor:
future_to_task = {executor.submit(upload_one, t, s, r, is_r): r for t, s, r, is_r in tasks}
for future in as_completed(future_to_task):
if not self.is_uploading: break
success, msg = future.result()
if success:
self.root.after(0, lambda m=msg: self.logger.info(f"✅ 成功: {m}"))
elif msg != "用户已取消":
self.root.after(0, lambda m=msg: self.logger.error(f"❌ 失败: {m}"))
self.root.after(0, self.upload_finished)
threading.Thread(target=upload_worker, daemon=True).start()
def update_ui_progress(self, percent, completed, total):
self.total_progress_var.set(percent)
self.progress_text.config(text=f"{completed} / {total} 个文件 ({percent:.1f}%)")
def mark_item_done(self, tree_item):
"""将表格中的某一行标记为已完成(灰色)"""
values = list(self.queue_tree.item(tree_item, "values"))
if len(values) >= 3:
values[2] = "已完成"
else:
values.append("已完成")
self.queue_tree.item(tree_item, values=values, tags=("done",))
def stop_upload(self):
"""强行停止上传"""
if messagebox.askyesno("确认停止", "确定要停止上传吗?\n(已传完的部分已保存在服务器,下次点上传会自动断点续传)"):
self.is_uploading = False
self.logger.warning("🛑 已中断后台请求,界面已重置。等待后台线程安全退出...")
self.upload_btn.config(state=tk.NORMAL, text="🚀 开始并发上传")
self.stop_btn.config(state=tk.DISABLED)
self.progress_text.config(text="已完成 / 已停止", fg="#2E7D32")
def upload_finished(self):
self.is_uploading = False
self.upload_btn.config(state=tk.NORMAL, text="🚀 开始并发上传")
self.stop_btn.config(state=tk.DISABLED)
self.progress_text.config(text="已完成 / 已停止", fg="#2E7D32")
self.logger.info("🎉 任务处理完毕!")
messagebox.showinfo("完成", "上传任务已结束(或已停止),请查看日志。")
def add_remote_to_queue(self):
url = self.remote_url_var.get().strip()
if not url:
messagebox.showwarning("提示", "请先粘贴 HF 文件链接")
return
# 自动拼合一个初始的目标路径(比如默认丢到 diffusion_models 下)
if url not in self.upload_queue:
self.upload_queue[url] = "" # 默认路径留空
self.refresh_queue()
self.remote_url_var.set("") # 加入后清空输入框
# 自动把最后一行高亮
children = self.queue_tree.get_children()
if children:
self.queue_tree.selection_set(children[-1])
if __name__ == "__main__":
root = tk.Tk()
app = HFUploaderApp(root)
root.mainloop() |