import os import json import threading import time import shutil from pathlib import Path from flask import Flask, jsonify, request, send_from_directory try: from dotenv import load_dotenv load_dotenv(override=True) except ImportError: pass try: from huggingface_hub import HfApi, snapshot_download, create_repo except ImportError: HfApi = None app = Flask(__name__, static_folder='frontend/dist') # Configuration DATA_DIR = Path("data") DATA_DIR.mkdir(parents=True, exist_ok=True) SKILLS_FILE = DATA_DIR / "skills.json" REPO_ID = os.getenv("HF_DB_REPO", "duqing2026/skill-tree-data") # Default repo, user should configure HF_TOKEN = os.getenv("HF_TOKEN") # Default initial data DEFAULT_SKILLS = { "nodes": [ { "id": "1", "type": "input", "data": { "label": "开始", "status": "completed", "description": "这是你的技能树起点。", "links": [] }, "position": {"x": 250, "y": 0} }, { "id": "2", "data": { "label": "学习 Python", "status": "in_progress", "description": "掌握 Python 基础语法和常用库。", "links": [{"title": "Python 官网", "url": "https://www.python.org"}] }, "position": {"x": 100, "y": 100} }, { "id": "3", "data": { "label": "学习 React", "status": "pending", "description": "学习组件化开发和 Hooks。", "links": [{"title": "React 文档", "url": "https://react.dev"}] }, "position": {"x": 400, "y": 100} }, ], "edges": [ {"id": "e1-2", "source": "1", "target": "2"}, {"id": "e1-3", "source": "1", "target": "3"}, ] } class SyncManager: def __init__(self, repo_id, token, data_dir): self.repo_id = repo_id self.token = token self.data_dir = data_dir self.api = HfApi(token=token) if HfApi and token else None self.is_pushing = False self.status = "online" # online, offline, auth_error self.last_error = None # Ensure repo exists if self.api: try: # Check connection first with a lightweight call self.api.whoami() self.status = "online" print(f"Sync: Ensuring repository {self.repo_id} exists...") create_repo( repo_id=self.repo_id, token=self.token, repo_type="dataset", exist_ok=True, private=True ) except Exception as e: # Suppress full stack trace for connection errors error_str = str(e) self.last_error = error_str if "MaxRetryError" in error_str or "SSLError" in error_str or "ConnectionError" in error_str: print(f"Sync: Warning - Could not connect to Hugging Face (Offline Mode). Sync disabled.") self.status = "offline" self.api = None # Disable sync for this session elif "401" in error_str or "Invalid user token" in error_str: print(f"Sync: Error - Invalid HF_TOKEN. Sync disabled. Please check your .env file.") self.status = "auth_error" self.api = None else: print(f"Sync: Warning - Could not ensure repo exists: {e}") self.status = "error" def pull(self): if not self.api: print("Sync: Skipping pull (no API/Token)") return print("Sync: Pulling data...") try: temp_dir = self.data_dir.parent / "temp_data_sync" if temp_dir.exists(): shutil.rmtree(temp_dir) temp_dir.mkdir() snapshot_download( repo_id=self.repo_id, repo_type="dataset", local_dir=temp_dir, token=self.token, allow_patterns=["skills.json"] ) # Simple overwrite strategy for now, as this is a personal tool # In a real multi-user scenario, we'd need smart merging src = temp_dir / "skills.json" dst = self.data_dir / "skills.json" if src.exists(): shutil.copy2(src, dst) print("Sync: Data pulled successfully.") else: print("Sync: Remote has no skills.json.") # If remote is empty but we have local data, push it to initialize remote if (self.data_dir / "skills.json").exists(): print("Sync: Initializing remote repository with local data...") self.push() shutil.rmtree(temp_dir) except Exception as e: error_str = str(e) if "404" in error_str or "Repository Not Found" in error_str: print(f"Sync: Remote data not found (new repo?). Starting with local data.") # If remote is empty but we have local data, push it to initialize remote if (self.data_dir / "skills.json").exists(): print("Sync: Initializing remote repository with local data...") self.push() else: print(f"Sync: Pull failed: {e}") def push(self): if not self.api: return if self.is_pushing: return print("Sync: Pushing data...") self.is_pushing = True try: self.api.upload_file( path_or_fileobj=self.data_dir / "skills.json", path_in_repo="skills.json", repo_id=self.repo_id, repo_type="dataset", commit_message=f"Update skills.json {int(time.time())}" ) print("Sync: Push successful.") except Exception as e: print(f"Sync: Push failed: {e}") finally: self.is_pushing = False sync_manager = SyncManager(REPO_ID, HF_TOKEN, DATA_DIR) # Initialize data if not SKILLS_FILE.exists(): # Try to pull first sync_manager.pull() # If still not exists, use default if not SKILLS_FILE.exists(): with open(SKILLS_FILE, "w") as f: json.dump(DEFAULT_SKILLS, f, indent=2) # New default data created, push it to remote print("Sync: Created default data. Pushing to remote...") sync_manager.push() else: # If local exists, try to pull (and auto-push if remote is empty via the catch block above) sync_manager.pull() @app.route("/") def serve_index(): if os.path.exists(os.path.join(app.static_folder, "index.html")): return send_from_directory(app.static_folder, "index.html") return "Frontend not built. Please run `npm run build` in frontend directory." @app.route("/") def serve_static(path): if os.path.exists(os.path.join(app.static_folder, path)): return send_from_directory(app.static_folder, path) return serve_index() @app.route("/api/skills", methods=["GET"]) def get_skills(): if SKILLS_FILE.exists(): with open(SKILLS_FILE, "r") as f: return jsonify(json.load(f)) return jsonify(DEFAULT_SKILLS) @app.route("/api/skills", methods=["POST"]) def save_skills(): data = request.json with open(SKILLS_FILE, "w") as f: json.dump(data, f, indent=2) # Trigger sync in background if sync_manager.api: threading.Thread(target=sync_manager.push).start() return jsonify({"status": "success"}) @app.route("/api/sync/status", methods=["GET"]) def get_sync_status(): return jsonify({ "status": sync_manager.status, "repo_id": sync_manager.repo_id, "last_error": sync_manager.last_error }) if __name__ == "__main__": port = int(os.getenv("PORT", 7860)) app.run(host="0.0.0.0", port=port)