diff --git a/backend/python/app/api/admin/privacy_admin.py b/backend/python/app/api/admin/privacy_admin.py new file mode 100644 index 0000000..522a704 --- /dev/null +++ b/backend/python/app/api/admin/privacy_admin.py @@ -0,0 +1,799 @@ +""" +Phase G5: Privacy & Local Agent Administration API +- Privacy config management (local/cloud/hybrid modes) +- Offline license generation & validation (JWT-based) +- Knowledge base sync state tracking +- Anonymized usage reporting +""" +import json +import hashlib +import hmac +import time +import uuid +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Depends, Query +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import SessionLocal +from ...auth import get_current_user + +router = APIRouter() + +PRIVACY_SECRET = "g5-privacy-secret-key-2026" +LICENSE_SECRET = "g5-license-hmac-secret-2026" + +# ======================================================================== +# Helpers +# ======================================================================== +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def _execute_raw(db: Session, sql_text, params=None): + """Execute raw SQL and return results""" + result = db.execute(sql_text, params or {}) + if result.returns_rows: + return result.fetchall() + db.commit() + return result + +def _row_to_dict(row, keys): + """Convert raw SQL row to dict""" + if row is None: + return None + d = {} + for i, k in enumerate(keys): + val = row[i] + if isinstance(val, datetime): + val = val.strftime("%Y-%m-%d %H:%M:%S") + d[k] = val + return d + +def _rows_to_list(rows, keys): + return [_row_to_dict(r, keys) for r in rows] + +def _anonymize_user_id(user_id: str) -> str: + """SHA256 hash for anonymized usage reporting""" + return hashlib.sha256(f"{user_id}:{PRIVACY_SECRET}".encode()).hexdigest() + +def _generate_offline_license(user_id: str, agent_id: str, max_days: int = 7) -> dict: + """Generate JWT-like offline license with HMAC signature""" + now = int(time.time()) + expires_at = now + max_days * 86400 + payload = { + "user_id": user_id, + "agent_id": agent_id, + "issued_at": now, + "expires_at": expires_at, + "max_offline_days": max_days, + "jti": uuid.uuid4().hex[:16], + "type": "offline_license" + } + payload_bytes = json.dumps(payload, sort_keys=True).encode() + signature = hmac.new( + LICENSE_SECRET.encode(), + payload_bytes, + hashlib.sha256 + ).hexdigest() + license_key = f"{payload_bytes.decode()}.{signature}" + return { + "license_key": license_key, + "payload": payload, + "expires_at": datetime.fromtimestamp(expires_at), + } + +def _validate_offline_license(license_key: str) -> dict: + """Validate offline license signature and expiry""" + if "." not in license_key: + raise ValueError("Invalid license format") + payload_str, signature = license_key.rsplit(".", 1) + expected_sig = hmac.new( + LICENSE_SECRET.encode(), + payload_str.encode(), + hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(signature, expected_sig): + raise ValueError("Invalid license signature") + payload = json.loads(payload_str) + now = int(time.time()) + if now > payload["expires_at"]: + raise ValueError("License expired") + return payload + + +# ======================================================================== +# 1. Privacy Config Management +# ======================================================================== +@router.get("/config") +def get_privacy_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get user's privacy configuration""" + result = _execute_raw(db, text( + "SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1" + ), {"uid": user_id}) + keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider", + "local_llm_model", "local_llm_endpoint", "data_retention_days", + "anonymize_usage", "sync_knowledge_bases", "sync_frequency", + "last_sync_at", "status", "created_at", "updated_at"] + if result: + row = result[0] + config = _row_to_dict(row, keys) + if config.get("sync_knowledge_bases"): + try: + config["sync_knowledge_bases"] = json.loads(config["sync_knowledge_bases"]) + except Exception: + pass + return {"code": 0, "data": config} + # Return default config + return {"code": 0, "data": { + "privacy_mode": "cloud", + "local_llm_provider": None, + "local_llm_model": None, + "local_llm_endpoint": None, + "data_retention_days": 30, + "anonymize_usage": True, + "sync_knowledge_bases": [], + "sync_frequency": "daily", + "status": "active" + }} + + +@router.put("/config") +def update_privacy_config( + privacy_mode: str = Query(None), + local_llm_provider: str = Query(None), + local_llm_model: str = Query(None), + local_llm_endpoint: str = Query(None), + data_retention_days: int = Query(None), + anonymize_usage: bool = Query(None), + sync_knowledge_bases: str = Query(None), + sync_frequency: str = Query(None), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Update privacy configuration""" + valid_modes = ["local", "cloud", "hybrid"] + valid_freqs = ["realtime", "hourly", "daily", "manual"] + + if privacy_mode and privacy_mode not in valid_modes: + raise HTTPException(400, f"Invalid mode: {privacy_mode}") + if sync_frequency and sync_frequency not in valid_freqs: + raise HTTPException(400, f"Invalid frequency: {sync_frequency}") + + # Check if config exists + existing = _execute_raw(db, text( + "SELECT id FROM privacy_configs WHERE user_id = :uid" + ), {"uid": user_id}) + + sets = [] + params = {"uid": user_id} + if privacy_mode: + sets.append("privacy_mode = :mode") + params["mode"] = privacy_mode + if local_llm_provider is not None: + sets.append("local_llm_provider = :provider") + params["provider"] = local_llm_provider + if local_llm_model is not None: + sets.append("local_llm_model = :model") + params["model"] = local_llm_model + if local_llm_endpoint is not None: + sets.append("local_llm_endpoint = :endpoint") + params["endpoint"] = local_llm_endpoint + if data_retention_days is not None: + sets.append("data_retention_days = :retention") + params["retention"] = data_retention_days + if anonymize_usage is not None: + sets.append("anonymize_usage = :anon") + params["anon"] = 1 if anonymize_usage else 0 + if sync_knowledge_bases is not None: + sets.append("sync_knowledge_bases = :kb_ids") + params["kb_ids"] = sync_knowledge_bases + if sync_frequency: + sets.append("sync_frequency = :freq") + params["freq"] = sync_frequency + + if not existing: + sets.append("user_id = :uid") + sets.append("created_at = NOW()") + sets.append("updated_at = NOW()") + sql = f"INSERT INTO privacy_configs SET {', '.join(sets)}" + else: + sets.append("updated_at = NOW()") + sql = f"UPDATE privacy_configs SET {', '.join(sets)} WHERE user_id = :uid" + + _execute_raw(db, text(sql), params) + return {"code": 0, "message": "Config updated"} + + +@router.get("/modes") +def list_privacy_modes(): + """List available privacy modes with descriptions""" + return {"code": 0, "data": [ + { + "mode": "cloud", + "name": "云端模式", + "description": "所有数据和计算在云端处理,适用于一般场景", + "pros": ["无需本地资源", "完整的平台功能", "自动更新"], + "cons": ["数据离开本地", "需要持续网络连接"], + "recommended_for": ["一般用户", "非敏感数据"] + }, + { + "mode": "local", + "name": "本地模式", + "description": "数据完全本地处理,仅通过Platform Bridge同步配置", + "pros": ["数据不离开本地", "完全隐私保护", "离线可用"], + "cons": ["需要本地GPU/NPU", "功能有限", "需手动同步"], + "recommended_for": ["政务", "金融", "医疗"] + }, + { + "mode": "hybrid", + "name": "混合模式", + "description": "敏感数据本地处理,通用能力云端调用", + "pros": ["灵活配置", "平衡隐私与功能"], + "cons": ["配置复杂", "需要网络连接"], + "recommended_for": ["企业级用户", "合规需求"] + } + ]} + + +@router.post("/switch-mode") +def switch_privacy_mode( + mode: str = Query(...), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Switch privacy mode with validation""" + valid_modes = ["local", "cloud", "hybrid"] + if mode not in valid_modes: + raise HTTPException(400, f"Invalid mode. Must be one of: {valid_modes}") + + existing = _execute_raw(db, text( + "SELECT id FROM privacy_configs WHERE user_id = :uid" + ), {"uid": user_id}) + + if existing: + _execute_raw(db, text( + "UPDATE privacy_configs SET privacy_mode = :mode, updated_at = NOW() WHERE user_id = :uid" + ), {"mode": mode, "uid": user_id}) + else: + _execute_raw(db, text( + "INSERT INTO privacy_configs (user_id, privacy_mode, created_at, updated_at) VALUES (:uid, :mode, NOW(), NOW())" + ), {"uid": user_id, "mode": mode}) + + return {"code": 0, "message": f"Switched to {mode} mode", "data": {"mode": mode}} + + +# ======================================================================== +# 2. Offline License Management +# ======================================================================== +@router.post("/licenses/generate") +def generate_offline_license( + agent_id: str = Query(None), + max_offline_days: int = Query(7), + device_fingerprint: str = Query(None), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Generate a new offline license""" + if max_offline_days < 1 or max_offline_days > 90: + raise HTTPException(400, "max_offline_days must be between 1 and 90") + + license_info = _generate_offline_license( + user_id, agent_id or "default", max_offline_days + ) + license_key = license_info["license_key"] + payload = license_info["payload"] + + _execute_raw(db, text(""" + INSERT INTO offline_licenses (license_key, user_id, agent_id, device_fingerprint, + issued_at, expires_at, max_offline_days, status) + VALUES (:lk, :uid, :aid, :fp, NOW(), :exp, :max_days, 'active') + """), { + "lk": license_key, + "uid": user_id, + "aid": agent_id, + "fp": device_fingerprint, + "exp": datetime.fromtimestamp(payload["expires_at"]), + "max_days": max_offline_days, + }) + + return {"code": 0, "data": { + "license_key": license_key, + "issued_at": payload["issued_at"], + "expires_at": payload["expires_at"], + "max_offline_days": max_offline_days, + "status": "active" + }} + + +@router.get("/licenses") +def list_offline_licenses( + user_id: str = Query("admin-001"), + status: str = Query(None), + page: int = Query(1), + page_size: int = Query(20), + db: Session = Depends(get_db) +): + """List user's offline licenses""" + where = "WHERE user_id = :uid" + params = {"uid": user_id} + if status: + where += " AND status = :st" + params["st"] = status + + count_row = _execute_raw(db, text( + f"SELECT COUNT(*) FROM offline_licenses {where}" + ), params) + total = count_row[0][0] if count_row else 0 + + offset = (page - 1) * page_size + rows = _execute_raw(db, text( + f"SELECT * FROM offline_licenses {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), {**params, "limit": page_size, "offset": offset}) + keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint", + "issued_at", "expires_at", "last_validated_at", "max_offline_days", + "is_active", "revoked_reason", "status", "created_at", "updated_at"] + + return {"code": 0, "data": { + "items": _rows_to_list(rows, keys), + "total": total, + "page": page, + "page_size": page_size + }} + + +@router.get("/licenses/{license_id}") +def get_license_detail(license_id: int, db: Session = Depends(get_db)): + """Get offline license detail""" + result = _execute_raw(db, text( + "SELECT * FROM offline_licenses WHERE id = :lid" + ), {"lid": license_id}) + keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint", + "issued_at", "expires_at", "last_validated_at", "max_offline_days", + "is_active", "revoked_reason", "status", "created_at", "updated_at"] + if not result: + raise HTTPException(404, "License not found") + return {"code": 0, "data": _row_to_dict(result[0], keys)} + + +@router.post("/licenses/{license_id}/refresh") +def refresh_license( + license_id: int, + extend_days: int = Query(7), + db: Session = Depends(get_db) +): + """Refresh an existing license (extend expiry)""" + result = _execute_raw(db, text( + "SELECT * FROM offline_licenses WHERE id = :lid" + ), {"lid": license_id}) + if not result: + raise HTTPException(404, "License not found") + + row = result[0] + status = row[11] # status column index + if status != "active": + raise HTTPException(400, f"Cannot refresh license with status: {status}") + + current_expires = row[5] # expires_at + if isinstance(current_expires, str): + current_expires = datetime.strptime(current_expires, "%Y-%m-%d %H:%M:%S") + new_expires = current_expires + timedelta(days=extend_days) + + _execute_raw(db, text( + "UPDATE offline_licenses SET expires_at = :exp, last_validated_at = NOW() WHERE id = :lid" + ), {"exp": new_expires, "lid": license_id}) + + return {"code": 0, "message": f"License extended by {extend_days} days", + "data": {"new_expires_at": new_expires.strftime("%Y-%m-%d %H:%M:%S")}} + + +@router.post("/licenses/validate") +def validate_license( + license_key: str = Query(...), + device_fingerprint: str = Query(None), + db: Session = Depends(get_db) +): + """Validate an offline license (called by local agent)""" + try: + payload = _validate_offline_license(license_key) + except ValueError as e: + return {"code": 1, "message": str(e), "data": {"valid": False}} + + # Check DB record + result = _execute_raw(db, text( + "SELECT id, status, expires_at, device_fingerprint FROM offline_licenses WHERE license_key = :lk" + ), {"lk": license_key}) + + if not result: + return {"code": 0, "data": {"valid": True, "warning": "License not in DB (may be pre-generated)"}} + + db_status = result[0][1] + if db_status != "active": + return {"code": 1, "message": f"License status: {db_status}", "data": {"valid": False}} + + # Update last_validated_at + _execute_raw(db, text( + "UPDATE offline_licenses SET last_validated_at = NOW() WHERE id = :lid" + ), {"lid": result[0][0]}) + + return {"code": 0, "data": { + "valid": True, + "user_id": payload["user_id"], + "agent_id": payload["agent_id"], + "expires_at": payload["expires_at"], + "max_offline_days": payload["max_offline_days"] + }} + + +@router.post("/licenses/{license_id}/revoke") +def revoke_license( + license_id: int, + reason: str = Query("Manually revoked"), + db: Session = Depends(get_db) +): + """Revoke an offline license""" + result = _execute_raw(db, text( + "SELECT id, status FROM offline_licenses WHERE id = :lid" + ), {"lid": license_id}) + if not result: + raise HTTPException(404, "License not found") + + _execute_raw(db, text( + "UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid" + ), {"reason": reason, "lid": license_id}) + + return {"code": 0, "message": "License revoked"} + + +# ======================================================================== +# 3. Knowledge Base Sync +# ======================================================================== +@router.get("/kb-sync") +def list_kb_sync_states( + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """List knowledge base sync states""" + rows = _execute_raw(db, text( + "SELECT * FROM kb_sync_states WHERE user_id = :uid ORDER BY updated_at DESC" + ), {"uid": user_id}) + keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version", + "last_sync_at", "sync_status", "total_docs", "synced_docs", + "sync_direction", "error_message", "created_at", "updated_at"] + + result = [] + for row in rows: + d = _row_to_dict(row, keys) + # Get KB name + kb_row = _execute_raw(db, text( + "SELECT name FROM knowledge_bases WHERE id = :kbid" + ), {"kbid": row[2]}) + d["kb_name"] = kb_row[0][0] if kb_row else f"KB-{row[2]}" + result.append(d) + + return {"code": 0, "data": result} + + +@router.post("/kb-sync/trigger") +def trigger_kb_sync( + knowledge_base_id: int = Query(...), + sync_direction: str = Query("pull"), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Trigger knowledge base sync""" + valid_dirs = ["push", "pull", "bidirectional"] + if sync_direction not in valid_dirs: + raise HTTPException(400, f"Invalid direction: {sync_direction}") + + # Check or create sync state + existing = _execute_raw(db, text( + "SELECT id FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid" + ), {"uid": user_id, "kbid": knowledge_base_id}) + + if existing: + _execute_raw(db, text(""" + UPDATE kb_sync_states SET sync_status = 'syncing', sync_direction = :dir, + last_sync_at = NOW(), error_message = NULL + WHERE id = :sid + """), {"dir": sync_direction, "sid": existing[0][0]}) + else: + _execute_raw(db, text(""" + INSERT INTO kb_sync_states (user_id, knowledge_base_id, sync_status, + sync_direction, last_sync_at) + VALUES (:uid, :kbid, 'syncing', :dir, NOW()) + """), {"uid": user_id, "kbid": knowledge_base_id, "dir": sync_direction}) + + # Simulate sync: count KB docs as "synced" + doc_count = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid" + ), {"kbid": knowledge_base_id}) + total = doc_count[0][0] if doc_count else 0 + + _execute_raw(db, text(""" + UPDATE kb_sync_states SET sync_status = 'synced', total_docs = :total, + synced_docs = :synced, remote_version = remote_version + 1, + error_message = NULL + WHERE user_id = :uid AND knowledge_base_id = :kbid + """), {"total": total, "synced": total, "uid": user_id, "kbid": knowledge_base_id}) + + return {"code": 0, "message": f"Sync triggered: {sync_direction}", + "data": {"total_docs": total, "synced_docs": total, "status": "synced"}} + + +@router.get("/kb-sync/{kb_id}/status") +def get_kb_sync_status(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get sync status for a specific KB""" + result = _execute_raw(db, text( + "SELECT * FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid" + ), {"uid": user_id, "kbid": kb_id}) + keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version", + "last_sync_at", "sync_status", "total_docs", "synced_docs", + "sync_direction", "error_message", "created_at", "updated_at"] + if not result: + return {"code": 0, "data": {"sync_status": "pending", "knowledge_base_id": kb_id}} + return {"code": 0, "data": _row_to_dict(result[0], keys)} + + +@router.get("/kb-sync/{kb_id}/diff") +def get_kb_sync_diff(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get document diff since last sync""" + result = _execute_raw(db, text( + "SELECT last_sync_at FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid" + ), {"uid": user_id, "kbid": kb_id}) + + last_sync = result[0][0] if result else None + + if last_sync: + new_docs = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND created_at > :since" + ), {"kbid": kb_id, "since": last_sync}) + updated_docs = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND updated_at > :since AND created_at <= :since" + ), {"kbid": kb_id, "since": last_sync}) + deleted_count = 0 # Soft delete not tracked in this version + else: + total = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid" + ), {"kbid": kb_id}) + total = total[0][0] if total else 0 + new_docs = [(total,)] + updated_docs = [(0,)] + deleted_count = 0 + + return {"code": 0, "data": { + "knowledge_base_id": kb_id, + "last_sync_at": last_sync.strftime("%Y-%m-%d %H:%M:%S") if last_sync and not isinstance(last_sync, str) else str(last_sync) if last_sync else None, + "new_docs": new_docs[0][0] if new_docs else 0, + "updated_docs": updated_docs[0][0] if updated_docs else 0, + "deleted_docs": deleted_count, + }} + + +# ======================================================================== +# 4. Anonymized Usage Reporting +# ======================================================================== +@router.post("/usage/report") +def submit_usage_report( + metrics: str = Query(...), + report_type: str = Query("daily"), + period_start: str = Query(None), + period_end: str = Query(None), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Submit anonymized usage report from local agent""" + user_hash = _anonymize_user_id(user_id) + + try: + metrics_obj = json.loads(metrics) + except json.JSONDecodeError: + metrics_obj = {"raw": metrics} + + if not period_start: + period_start = datetime.now().strftime("%Y-%m-%d 00:00:00") + if not period_end: + period_end = datetime.now().strftime("%Y-%m-%d 23:59:59") + + _execute_raw(db, text(""" + INSERT INTO usage_reports (user_hash, report_type, metrics, period_start, + period_end, is_anonymized, submitted_at) + VALUES (:hash, :rtype, :metrics, :pstart, :pend, 1, NOW()) + """), { + "hash": user_hash, + "rtype": report_type, + "metrics": json.dumps(metrics_obj), + "pstart": period_start, + "pend": period_end, + }) + + return {"code": 0, "message": "Usage report submitted"} + + +@router.get("/usage/stats") +def get_usage_stats(db: Session = Depends(get_db)): + """Get aggregated usage statistics (admin view, anonymized)""" + total_reports = _execute_raw(db, text( + "SELECT COUNT(*) FROM usage_reports" + )) + unique_users = _execute_raw(db, text( + "SELECT COUNT(DISTINCT user_hash) FROM usage_reports" + )) + today_reports = _execute_raw(db, text( + "SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()" + )) + by_type = _execute_raw(db, text( + "SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type" + )) + + return {"code": 0, "data": { + "total_reports": total_reports[0][0] if total_reports else 0, + "unique_users": unique_users[0][0] if unique_users else 0, + "today_reports": today_reports[0][0] if today_reports else 0, + "by_type": [{"type": r[0], "count": r[1]} for r in by_type] if by_type else [] + }} + + +@router.get("/usage/my-stats") +def get_my_usage_stats(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get current user's usage stats""" + user_hash = _anonymize_user_id(user_id) + result = _execute_raw(db, text( + "SELECT COUNT(*), MAX(submitted_at) FROM usage_reports WHERE user_hash = :hash" + ), {"hash": user_hash}) + + return {"code": 0, "data": { + "total_reports": result[0][0] if result else 0, + "last_submitted": _row_to_dict(result[0], ["count", "last"])["last"] if result else None + }} + + +# ======================================================================== +# 5. Local Agent Status +# ======================================================================== +@router.get("/local-agents") +def list_local_agents(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """List local agents with sync status""" + # Query agents with privacy configs + rows = _execute_raw(db, text(""" + SELECT DISTINCT a.id, a.name, a.description, a.status, a.type, + COALESCE(pc.privacy_mode, 'cloud') as privacy_mode, + COALESCE(pc.local_llm_provider, '') as llm_provider, + COALESCE(pc.local_llm_model, '') as llm_model, + COALESCE(pc.last_sync_at, NULL) as last_sync + FROM market_apps a + LEFT JOIN privacy_configs pc ON pc.agent_id = a.id AND pc.user_id = :uid + WHERE a.type = 'agent' AND a.status = 'published' + ORDER BY a.name + """), {"uid": user_id}) + keys = ["id", "name", "description", "status", "type", "privacy_mode", + "llm_provider", "llm_model", "last_sync"] + + agents = [] + for row in rows: + d = _row_to_dict(row, keys) + d["can_run_local"] = d["privacy_mode"] in ["local", "hybrid"] + agents.append(d) + + return {"code": 0, "data": agents} + + +@router.get("/local-agents/{agent_id}/status") +def get_local_agent_status( + agent_id: str, + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Get detailed local agent status""" + config = _execute_raw(db, text( + "SELECT * FROM privacy_configs WHERE user_id = :uid AND agent_id = :aid" + ), {"uid": user_id, "aid": agent_id}) + keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider", + "local_llm_model", "local_llm_endpoint", "data_retention_days", + "anonymize_usage", "sync_knowledge_bases", "sync_frequency", + "last_sync_at", "status", "created_at", "updated_at"] + + if config: + cfg = _row_to_dict(config[0], keys) + if cfg.get("sync_knowledge_bases"): + try: + cfg["sync_knowledge_bases"] = json.loads(cfg["sync_knowledge_bases"]) + except Exception: + pass + else: + cfg = {"privacy_mode": "cloud", "status": "not_configured"} + + # Check active license + license_info = _execute_raw(db, text( + "SELECT license_key, expires_at, status FROM offline_licenses WHERE user_id = :uid AND status = 'active' ORDER BY expires_at DESC LIMIT 1" + ), {"uid": user_id}) + if license_info: + cfg["has_active_license"] = True + cfg["license_expires"] = license_info[0][1].strftime("%Y-%m-%d %H:%M:%S") if hasattr(license_info[0][1], 'strftime') else str(license_info[0][1]) + else: + cfg["has_active_license"] = False + cfg["license_expires"] = None + + return {"code": 0, "data": cfg} + + +# ======================================================================== +# 6. Platform Bridge Config Export +# ======================================================================== +@router.get("/bridge-config") +def get_platform_bridge_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Export Platform Bridge configuration for local Goose setup""" + config = _execute_raw(db, text( + "SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1" + ), {"uid": user_id}) + + platform_api = "http://210.16.161.218:18080" + bridge_config = { + "mcp_server": { + "name": "platform-bridge", + "type": "stdio", + "command": "python", + "args": ["-m", "platform_bridge.mcp_server"], + "env": { + "PLATFORM_API_URL": platform_api, + "PLATFORM_USER_ID": user_id, + "PLATFORM_API_KEY": "YOUR_API_KEY_HERE" + }, + "timeout": 60 + }, + "privacy_mode": "cloud", + "sync_config": { + "frequency": "daily", + "knowledge_bases": [], + "auto_sync": True, + "conflict_resolution": "last_write_wins" + }, + "local_llm": { + "provider": "ollama", + "model": "qwen2.5:7b", + "endpoint": "http://localhost:11434" + } + } + + if config: + row = config[0] + bridge_config["privacy_mode"] = row[3] # privacy_mode column + bridge_config["sync_config"]["frequency"] = row[10] # sync_frequency column + if row[5]: # local_llm_provider + bridge_config["local_llm"]["provider"] = row[5] + if row[6]: # local_llm_model + bridge_config["local_llm"]["model"] = row[6] + if row[7]: # local_llm_endpoint + bridge_config["local_llm"]["endpoint"] = row[7] + if row[9]: # sync_knowledge_bases + try: + bridge_config["sync_config"]["knowledge_bases"] = json.loads(row[9]) + except Exception: + pass + + return {"code": 0, "data": bridge_config} + + +@router.get("/stats") +def get_privacy_stats(db: Session = Depends(get_db)): + """Get privacy module statistics""" + total_configs = _execute_raw(db, text("SELECT COUNT(*) FROM privacy_configs")) + active_licenses = _execute_raw(db, text( + "SELECT COUNT(*) FROM offline_licenses WHERE status = 'active' AND expires_at > NOW()" + )) + synced_kbs = _execute_raw(db, text( + "SELECT COUNT(*) FROM kb_sync_states WHERE sync_status = 'synced'" + )) + mode_breakdown = _execute_raw(db, text( + "SELECT privacy_mode, COUNT(*) FROM privacy_configs GROUP BY privacy_mode" + )) + + return {"code": 0, "data": { + "total_configs": total_configs[0][0] if total_configs else 0, + "active_licenses": active_licenses[0][0] if active_licenses else 0, + "synced_knowledge_bases": synced_kbs[0][0] if synced_kbs else 0, + "mode_breakdown": {r[0]: r[1] for r in mode_breakdown} if mode_breakdown else {} + }} diff --git a/backend/python/main.py b/backend/python/main.py index a4f5d7f..4f73222 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin from app.api.admin.developer_tools_admin import router as developer_tools_router from app.api.admin.mcp_market_admin import router as mcp_market_router from app.api.admin import asset_admin @@ -433,6 +433,7 @@ app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) +app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b179212..3e6d5da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,8 @@ import AdminLogs from '@/pages/admin/Logs'; import AdminSkills from '@/pages/admin/Skills' import AdminAgents from '@/pages/admin/Agents'; import AdminCodeExec from '@/pages/admin/CodeExec';; +import DistributionBuilder from '@/pages/admin/DistributionBuilder'; +import PrivacySettings from '@/pages/admin/PrivacySettings'; const App: React.FC = () => { const [user, setUser] = useState(null); @@ -117,6 +119,8 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index ec00298..6544160 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -776,4 +776,42 @@ export const evolutionAPI = { executeWorkflow: (id: string, inputText: string) => post(`/admin/workflows/${id}/execute`, { input_text: inputText }), + + // Privacy & Local Agent (Phase G5) + getPrivacyStats: () => get('/admin/privacy/stats'), + getPrivacyConfig: () => get('/admin/privacy/config'), + updatePrivacyConfig: (params: any) => { + const sp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { if (v != null) sp.append(k, String(v)); }); + return put('/admin/privacy/config?' + sp.toString()); + }, + switchPrivacyMode: (mode: string) => post('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)), + getPrivacyLicenses: (params?: any) => { + const sp = new URLSearchParams(); + if (params?.status) sp.append('status', params.status); + return get('/admin/privacy/licenses' + (sp.toString() ? '?' + sp.toString() : '')); + }, + generateOfflineLicense: (data: any) => { + const sp = new URLSearchParams(); + if (data.agent_id) sp.append('agent_id', data.agent_id); + sp.append('max_offline_days', String(data.max_offline_days || 7)); + return post('/admin/privacy/licenses/generate?' + sp.toString()); + }, + revokePrivacyLicense: (id: number) => post('/admin/privacy/licenses/' + id + '/revoke'), + validatePrivacyLicense: (licenseKey: string) => { + const sp = new URLSearchParams(); + sp.append('license_key', licenseKey); + return post('/admin/privacy/licenses/validate?' + sp.toString()); + }, + getKbSyncStates: () => get('/admin/privacy/kb-sync'), + triggerKbSync: (kbId: number, direction: string) => { + const sp = new URLSearchParams(); + sp.append('knowledge_base_id', String(kbId)); + sp.append('sync_direction', direction); + return post('/admin/privacy/kb-sync/trigger?' + sp.toString()); + }, + getLocalAgents: () => get('/admin/privacy/local-agents'), + getPrivacyUsageStats: () => get('/admin/privacy/usage/stats'), + getBridgeConfig: () => get('/admin/privacy/bridge-config'), + }; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index b937a7a..2c3289e 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -19,6 +19,7 @@ const adminNavItems = [ { path: '/admin/skills', icon: 'skills', label: '技能管理' }, { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, { path: '/admin/distributions', icon: 'distro', label: '发行版构建' }, + { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, ]; const adminIconPaths: Record = { @@ -35,7 +36,12 @@ const adminIconPaths: Record = { skills: , code: , - distro: ,}; + distro: , + privacy: + + + + ,}; const AdminLayout: React.FC = () => { const { user, logout } = useAuth(); diff --git a/frontend/src/pages/admin/PrivacySettings.tsx b/frontend/src/pages/admin/PrivacySettings.tsx new file mode 100644 index 0000000..970066f --- /dev/null +++ b/frontend/src/pages/admin/PrivacySettings.tsx @@ -0,0 +1,742 @@ +// @ts-nocheck +import React, { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; + +interface Stats { + total_configs: number; + active_licenses: number; + synced_knowledge_bases: number; + mode_breakdown: Record; +} + +interface PrivacyConfig { + privacy_mode: string; + local_llm_provider: string; + local_llm_model: string; + local_llm_endpoint: string; + data_retention_days: number; + anonymize_usage: boolean; + sync_knowledge_bases: number[]; + sync_frequency: string; + status: string; + last_sync_at?: string; +} + +interface License { + id: number; + license_key: string; + agent_id: number | null; + issued_at: string; + expires_at: string; + max_offline_days: number; + status: string; + device_fingerprint?: string; +} + +interface KbSyncState { + id: number; + knowledge_base_id: number; + kb_name: string; + sync_status: string; + total_docs: number; + synced_docs: number; + sync_direction: string; + last_sync_at: string | null; + local_version: number; + remote_version: number; +} + +interface LocalAgent { + id: number; + name: string; + privacy_mode: string; + can_run_local: boolean; + llm_provider: string; + last_sync: string | null; +} + +interface UsageStats { + total_reports: number; + unique_users: number; + today_reports: number; + by_type: Array<{ type: string; count: number }>; +} + +const MODE_OPTIONS = [ + { value: 'cloud', label: '云端模式', icon: '☁️', desc: '所有数据和计算在云端处理' }, + { value: 'local', label: '本地模式', icon: '🖥️', desc: '数据完全本地处理,隐私优先' }, + { value: 'hybrid', label: '混合模式', icon: '🔀', desc: '敏感数据本地,通用能力云端' }, +]; + +const FREQ_OPTIONS = [ + { value: 'realtime', label: '实时' }, + { value: 'hourly', label: '每小时' }, + { value: 'daily', label: '每天' }, + { value: 'manual', label: '手动' }, +]; + +export default function PrivacySettings() { + const [activeTab, setActiveTab] = useState('settings'); + const [stats, setStats] = useState(null); + const [config, setConfig] = useState(null); + const [licenses, setLicenses] = useState([]); + const [kbStates, setKbStates] = useState([]); + const [localAgents, setLocalAgents] = useState([]); + const [usageStats, setUsageStats] = useState(null); + const [bridgeConfig, setBridgeConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + + // Modal states + const [showLicenseModal, setShowLicenseModal] = useState(false); + const [showSyncModal, setShowSyncModal] = useState(false); + const [licenseForm, setLicenseForm] = useState({ agent_id: '', max_offline_days: 7 }); + const [syncForm, setSyncForm] = useState({ kb_id: '', direction: 'pull' }); + const [newLicense, setNewLicense] = useState(null); + const [validatingLicense, setValidatingLicense] = useState(''); + + const showMsg = (msg: string) => { setMessage(msg); setTimeout(() => setMessage(''), 3000); }; + + const loadStats = useCallback(async () => { + try { + const res = await adminAPI.getPrivacyStats(); + if (res?.code === 0) setStats(res.data); + } catch (e) { console.error('loadStats:', e); } + }, []); + + const loadConfig = useCallback(async () => { + try { + const res = await adminAPI.getPrivacyConfig(); + if (res?.code === 0) setConfig(res.data); + } catch (e) { console.error('loadConfig:', e); } + }, []); + + const loadLicenses = useCallback(async () => { + try { + const res = await adminAPI.getPrivacyLicenses(); + if (res?.code === 0) setLicenses(res.data?.items || []); + } catch (e) { console.error('loadLicenses:', e); } + }, []); + + const loadKbStates = useCallback(async () => { + try { + const res = await adminAPI.getKbSyncStates(); + if (res?.code === 0) setKbStates(res.data || []); + } catch (e) { console.error('loadKbStates:', e); } + }, []); + + const loadLocalAgents = useCallback(async () => { + try { + const res = await adminAPI.getLocalAgents(); + if (res?.code === 0) setLocalAgents(res.data || []); + } catch (e) { console.error('loadLocalAgents:', e); } + }, []); + + const loadUsage = useCallback(async () => { + try { + const res = await adminAPI.getPrivacyUsageStats(); + if (res?.code === 0) setUsageStats(res.data); + } catch (e) { console.error('loadUsage:', e); } + }, []); + + const loadBridgeConfig = useCallback(async () => { + try { + const res = await adminAPI.getBridgeConfig(); + if (res?.code === 0) setBridgeConfig(res.data); + } catch (e) { console.error('loadBridge:', e); } + }, []); + + useEffect(() => { + loadStats(); + loadConfig(); + loadLicenses(); + loadKbStates(); + loadLocalAgents(); + loadUsage(); + loadBridgeConfig(); + }, [loadStats, loadConfig, loadLicenses, loadKbStates, loadLocalAgents, loadUsage, loadBridgeConfig]); + + const switchMode = async (mode: string) => { + setLoading(true); + try { + const res = await adminAPI.switchPrivacyMode(mode); + if (res?.code === 0) { showMsg(`已切换到${MODE_OPTIONS.find(m => m.value === mode)?.label}`); loadConfig(); loadStats(); } + } catch (e) { showMsg('切换失败'); } + setLoading(false); + }; + + const updateConfig = async (field: string, value: any) => { + setLoading(true); + try { + const params: any = {}; + if (field === 'privacy_mode') params.privacy_mode = value; + else if (field === 'local_llm_provider') params.local_llm_provider = value; + else if (field === 'local_llm_model') params.local_llm_model = value; + else if (field === 'local_llm_endpoint') params.local_llm_endpoint = value; + else if (field === 'data_retention_days') params.data_retention_days = value; + else if (field === 'sync_frequency') params.sync_frequency = value; + const res = await adminAPI.updatePrivacyConfig(params); + if (res?.code === 0) { showMsg('配置已更新'); loadConfig(); } + } catch (e) { showMsg('更新失败'); } + setLoading(false); + }; + + const generateLicense = async () => { + setLoading(true); + try { + const res = await adminAPI.generateOfflineLicense({ + agent_id: licenseForm.agent_id || undefined, + max_offline_days: licenseForm.max_offline_days, + }); + if (res?.code === 0) { + setNewLicense(res.data); + loadLicenses(); + loadStats(); + showMsg('许可证已生成'); + } + } catch (e) { showMsg('生成失败'); } + setLoading(false); + }; + + const revokeLicense = async (id: number) => { + if (!confirm('确定吊销此许可证?')) return; + try { + await adminAPI.revokePrivacyLicense(id); + showMsg('已吊销'); loadLicenses(); loadStats(); + } catch (e) { showMsg('吊销失败'); } + }; + + const validateLicenseKey = async () => { + if (!validatingLicense) return; + setLoading(true); + try { + const res = await adminAPI.validatePrivacyLicense(validatingLicense); + if (res?.data?.valid) showMsg('✅ 许可证有效'); + else showMsg('❌ ' + (res?.message || '许可证无效')); + } catch (e) { showMsg('验证失败'); } + setLoading(false); + }; + + const triggerSync = async () => { + if (!syncForm.kb_id) return; + setLoading(true); + try { + const res = await adminAPI.triggerKbSync(parseInt(syncForm.kb_id), syncForm.direction); + if (res?.code === 0) { showMsg('同步已触发'); loadKbStates(); loadStats(); setShowSyncModal(false); } + } catch (e) { showMsg('同步失败'); } + setLoading(false); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => showMsg('已复制到剪贴板')); + }; + + const formatDate = (d: string | null) => d ? new Date(d).toLocaleString('zh-CN') : '-'; + + const TABS = [ + { key: 'settings', label: '隐私设置', icon: '🔒' }, + { key: 'licenses', label: '许可证管理', icon: '🔑' }, + { key: 'kb_sync', label: '知识库同步', icon: '🔄' }, + { key: 'agents', label: '本地智能体', icon: '🤖' }, + { key: 'bridge', label: '平台桥接', icon: '🌉' }, + { key: 'usage', label: '用量统计', icon: '📊' }, + ]; + + return ( +
+ {/* Header */} +
+
+

🔐 隐私与本地智能体

+

管理隐私模式、本地代理、离线许可证和知识库同步

+
+
+ + {message && ( +
+ {message} +
+ )} + + {/* Stats Cards */} + {stats && ( +
+ {[ + { label: '隐私配置', value: stats.total_configs, color: '#7c3aed' }, + { label: '活跃许可证', value: stats.active_licenses, color: '#059669' }, + { label: '已同步知识库', value: stats.synced_knowledge_bases, color: '#2563eb' }, + { label: '本地模式', value: stats.mode_breakdown?.local || 0, color: '#ea580c' }, + { label: '混合模式', value: stats.mode_breakdown?.hybrid || 0, color: '#ca8a04' }, + { label: '云端模式', value: stats.mode_breakdown?.cloud || 0, color: '#6366f1' }, + ].map((s, i) => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ )} + + {/* Tabs */} +
+ {TABS.map(t => ( + + ))} +
+ + {/* Tab Content */} +
+ {/* ============ TAB: Privacy Settings ============ */} + {activeTab === 'settings' && ( +
+

隐私模式选择

+
+ {MODE_OPTIONS.map(m => ( +
!loading && switchMode(m.value)} + style={{ + background: config?.privacy_mode === m.value ? '#2d1f5e' : '#252540', + border: config?.privacy_mode === m.value ? '2px solid #7c3aed' : '2px solid transparent', + borderRadius: 10, + padding: 20, + cursor: 'pointer', + transition: 'all 0.2s', + opacity: loading ? 0.6 : 1, + }} + > +
{m.icon}
+
{m.label}
+
{m.desc}
+ {config?.privacy_mode === m.value && ( +
✓ 当前模式
+ )} +
+ ))} +
+ + {/* Local LLM Config */} + {(config?.privacy_mode === 'local' || config?.privacy_mode === 'hybrid') && ( +
+

本地 LLM 配置

+
+
+ + +
+
+ + updateConfig('local_llm_model', e.target.value)} + style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13 }} + /> +
+
+ + updateConfig('local_llm_endpoint', e.target.value)} + style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13 }} + /> +
+
+
+ )} + + {/* Sync Config */} +
+

同步配置

+
+
+ + +
+
+ + updateConfig('data_retention_days', parseInt(e.target.value))} + style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13 }} + /> +
+
+
+ + {/* Status */} +
+
当前状态
+
+
模式: {config?.privacy_mode || 'cloud'}
+
状态: {config?.status || 'active'}
+
匿名上报: {config?.anonymize_usage ? '已启用' : '已禁用'}
+
最后同步: {config?.last_sync_at ? formatDate(config.last_sync_at) : '从未'}
+
+
+
+ )} + + {/* ============ TAB: Licenses ============ */} + {activeTab === 'licenses' && ( +
+
+

离线许可证管理

+
+ setValidatingLicense(e.target.value)} + style={{ padding: '8px 12px', background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13, width: 280 }} + /> + + +
+
+ + {/* License Table */} + + + + {['ID', '许可证密钥', 'Agent ID', '最大离线天数', '签发时间', '过期时间', '状态', '操作'].map(h => ( + + ))} + + + + {licenses.length === 0 ? ( + + ) : licenses.map(l => ( + + + + + + + + + + + ))} + +
{h}
暂无许可证
{l.id} + {l.license_key?.substring(0, 40)}... + {l.agent_id || '-'}{l.max_offline_days} 天{formatDate(l.issued_at)}{formatDate(l.expires_at)} + {l.status} + + {l.status === 'active' && ( + + )} +
+ + {/* License Modal */} + {showLicenseModal && ( +
+
+

{newLicense ? '✅ 许可证已生成' : '生成离线许可证'}

+ {newLicense ? ( +
+
+
许可证密钥 (点击复制)
+
copyToClipboard(newLicense.license_key)} style={{ + fontFamily: 'monospace', fontSize: 11, color: '#4ade80', wordBreak: 'break-all', + cursor: 'pointer', background: '#0f0f1a', padding: 10, borderRadius: 6, maxHeight: 100, overflow: 'auto' + }}> + {newLicense.license_key} +
+
+
+
签发时间: {formatDate(newLicense.issued_at ? new Date(newLicense.issued_at * 1000).toISOString() : null)}
+
过期时间: {formatDate(newLicense.expires_at ? new Date(newLicense.expires_at * 1000).toISOString() : null)}
+
离线天数: {newLicense.max_offline_days} 天
+
状态: 活跃
+
+ +
+ ) : ( +
+
+ + +
+
+ + setLicenseForm({ ...licenseForm, max_offline_days: parseInt(e.target.value) })} + style={{ width: '100%' }} /> +
+
+ + +
+
+ )} +
+
+ )} +
+ )} + + {/* ============ TAB: KB Sync ============ */} + {activeTab === 'kb_sync' && ( +
+
+

知识库同步状态

+ +
+ + + + + {['知识库', '状态', '方向', '文档数', '已同步', '本地版本', '远程版本', '最后同步'].map(h => ( + + ))} + + + + {kbStates.length === 0 ? ( + + ) : kbStates.map(kb => ( + + + + + + + + + + + ))} + +
{h}
暂无同步记录
{kb.kb_name} + {kb.sync_status} + {kb.sync_direction}{kb.total_docs}{kb.synced_docs}v{kb.local_version}v{kb.remote_version}{formatDate(kb.last_sync_at)}
+ + {/* Sync Modal */} + {showSyncModal && ( +
+
+

触发知识库同步

+
+ + setSyncForm({ ...syncForm, kb_id: e.target.value })} + placeholder="输入知识库 ID" style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6 }} /> +
+
+ + +
+
+ + +
+
+
+ )} +
+ )} + + {/* ============ TAB: Local Agents ============ */} + {activeTab === 'agents' && ( +
+

本地智能体状态

+
+ {localAgents.length === 0 ? ( +
暂无已发布的 Agent
+ ) : localAgents.map(agent => ( +
+
+
+
{agent.name}
+
ID: {agent.id}
+
+ {agent.privacy_mode} +
+
+ {agent.can_run_local ? ( + ✓ 支持本地运行 ({agent.llm_provider || '未配置LLM'}) + ) : ( + ✗ 仅云端运行 + )} +
+
+ 最后同步: {formatDate(agent.last_sync)} +
+
+ ))} +
+
+ )} + + {/* ============ TAB: Platform Bridge ============ */} + {activeTab === 'bridge' && ( +
+

Platform Bridge 配置

+

+ 将此配置部署到本地 Goose,即可通过 Platform Bridge MCP 扩展连接云端平台。 +

+ + {bridgeConfig && ( +
+ {/* MCP Server Config */} +
+

MCP 服务器配置

+
+                    {JSON.stringify(bridgeConfig.mcp_server, null, 2)}
+                  
+ +
+ + {/* Sync & LLM Config */} +
+

同步 & LLM 配置

+
+                    {JSON.stringify({ sync_config: bridgeConfig.sync_config, local_llm: bridgeConfig.local_llm, privacy_mode: bridgeConfig.privacy_mode }, null, 2)}
+                  
+ + + {/* Deployment Instructions */} +
+

🚀 部署步骤

+
    +
  1. 复制 Platform Bridge 代码到本地 ~/.goose/extensions/platform-bridge/
  2. +
  3. 安装依赖: pip install -r requirements.txt
  4. +
  5. 配置环境变量: 将上方 MCP 服务器配置添加到 Goose
  6. +
  7. 启动 Goose: 配置将自动连接到平台
  8. +
+
+
+
+ )} +
+ )} + + {/* ============ TAB: Usage ============ */} + {activeTab === 'usage' && ( +
+

匿名化用量统计

+ {usageStats && ( +
+
+
+
总上报数
+
{usageStats.total_reports}
+
+
+
匿名用户数
+
{usageStats.unique_users}
+
+
+
今日上报
+
{usageStats.today_reports}
+
+
+ +

按类型分布

+
+ {usageStats.by_type?.map((t, i) => ( +
+
{t.type}
+
{t.count}
+
+ ))} +
+ +
+

📋 隐私说明

+

+ 所有用量数据在上报前均已通过 SHA-256 哈希匿名化处理。我们仅收集聚合统计数据,不包含任何可识别个人身份的信息。 + 数据包括:执行次数、模型调用次数、知识库查询次数、Agent 运行时统计等。 +

+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/migrations/mig_g5_privacy.sql b/migrations/mig_g5_privacy.sql new file mode 100644 index 0000000..ac0fea6 --- /dev/null +++ b/migrations/mig_g5_privacy.sql @@ -0,0 +1,89 @@ +-- Phase G5: Local Agent / Privacy Mode — Database Migration +-- Tables: privacy_configs, offline_licenses, kb_sync_states, usage_reports +-- Also: ALTER distributions ADD COLUMN privacy_mode + +-- 1. Privacy Configurations (per-user privacy settings) +CREATE TABLE IF NOT EXISTS privacy_configs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + agent_id VARCHAR(64) DEFAULT NULL COMMENT 'FK to market_apps.id', + privacy_mode ENUM('local', 'cloud', 'hybrid') NOT NULL DEFAULT 'cloud', + local_llm_provider VARCHAR(64) DEFAULT NULL COMMENT 'ollama/vllm/localai', + local_llm_model VARCHAR(128) DEFAULT NULL, + local_llm_endpoint VARCHAR(256) DEFAULT NULL COMMENT 'http://localhost:11434', + data_retention_days INT NOT NULL DEFAULT 30 COMMENT 'Local data retention period', + anonymize_usage TINYINT(1) NOT NULL DEFAULT 1, + sync_knowledge_bases JSON DEFAULT NULL COMMENT 'KB IDs to sync locally', + sync_frequency ENUM('realtime', 'hourly', 'daily', 'manual') NOT NULL DEFAULT 'daily', + last_sync_at DATETIME DEFAULT NULL, + status ENUM('active', 'inactive', 'error') NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_privacy_user (user_id), + INDEX idx_privacy_agent (agent_id), + INDEX idx_privacy_mode (privacy_mode) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Offline Licenses +CREATE TABLE IF NOT EXISTS offline_licenses ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + license_key VARCHAR(256) NOT NULL UNIQUE COMMENT 'JWT token', + user_id VARCHAR(64) NOT NULL, + agent_id BIGINT DEFAULT NULL, + device_fingerprint VARCHAR(256) DEFAULT NULL COMMENT 'SHA256 of machine-id + MAC', + issued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_validated_at DATETIME DEFAULT NULL, + max_offline_days INT NOT NULL DEFAULT 7, + is_active TINYINT(1) NOT NULL DEFAULT 1, + revoked_reason VARCHAR(512) DEFAULT NULL, + status ENUM('active', 'expired', 'revoked', 'pending') NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_ol_user (user_id), + INDEX idx_ol_license (license_key), + INDEX idx_ol_status (status), + INDEX idx_ol_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. Knowledge Base Sync States +CREATE TABLE IF NOT EXISTS kb_sync_states ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + knowledge_base_id BIGINT NOT NULL COMMENT 'FK to knowledge_bases.id', + local_version INT NOT NULL DEFAULT 0, + remote_version INT NOT NULL DEFAULT 0, + last_sync_at DATETIME DEFAULT NULL, + sync_status ENUM('synced', 'syncing', 'pending', 'conflict', 'failed') NOT NULL DEFAULT 'pending', + total_docs INT NOT NULL DEFAULT 0, + synced_docs INT NOT NULL DEFAULT 0, + sync_direction ENUM('push', 'pull', 'bidirectional') NOT NULL DEFAULT 'pull', + error_message VARCHAR(1024) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_kbs_user (user_id), + INDEX idx_kbs_kb (knowledge_base_id), + INDEX idx_kbs_status (sync_status), + UNIQUE KEY uk_user_kb (user_id, knowledge_base_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. Usage Reports (anonymized) +CREATE TABLE IF NOT EXISTS usage_reports ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_hash VARCHAR(64) NOT NULL COMMENT 'SHA256 anonymized user ID', + report_type ENUM('daily', 'weekly', 'monthly', 'realtime') NOT NULL DEFAULT 'daily', + metrics JSON NOT NULL COMMENT 'Aggregated usage metrics', + period_start DATETIME NOT NULL, + period_end DATETIME NOT NULL, + is_anonymized TINYINT(1) NOT NULL DEFAULT 1, + submitted_at DATETIME DEFAULT NULL COMMENT 'When local agent submitted', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_ur_hash (user_hash), + INDEX idx_ur_type (report_type), + INDEX idx_ur_period (period_start, period_end) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5. Add privacy_mode to distributions table +ALTER TABLE distributions + ADD COLUMN IF NOT EXISTS privacy_mode ENUM('local', 'cloud', 'hybrid') NOT NULL DEFAULT 'cloud' + COMMENT 'Deployment mode for distribution' AFTER status;