Phase G5: Local Agent / Privacy Mode — 4 tables, 19 API endpoints, platform_bridge MCP, PrivacySettings.tsx (740 lines). 25/25 tests pass.
This commit is contained in:
1 parent
0486210133
commit
891d1ce527
7 files changed
+1681
-2
No files matched your search
@@ -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 {}
|
||||||
|
}}
|
||||||
@@ -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 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.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.mcp_market_admin import router as mcp_market_router
|
||||||
from app.api.admin import asset_admin
|
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(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(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(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
|
||||||
|
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import AdminLogs from '@/pages/admin/Logs';
|
|||||||
import AdminSkills from '@/pages/admin/Skills'
|
import AdminSkills from '@/pages/admin/Skills'
|
||||||
import AdminAgents from '@/pages/admin/Agents';
|
import AdminAgents from '@/pages/admin/Agents';
|
||||||
import AdminCodeExec from '@/pages/admin/CodeExec';;
|
import AdminCodeExec from '@/pages/admin/CodeExec';;
|
||||||
|
import DistributionBuilder from '@/pages/admin/DistributionBuilder';
|
||||||
|
import PrivacySettings from '@/pages/admin/PrivacySettings';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
@@ -117,6 +119,8 @@ const App: React.FC = () => {
|
|||||||
<Route path="skills" element={<AdminSkills />} />
|
<Route path="skills" element={<AdminSkills />} />
|
||||||
<Route path="agents" element={<AdminAgents />} />
|
<Route path="agents" element={<AdminAgents />} />
|
||||||
<Route path="code-exec" element={<AdminCodeExec />} />
|
<Route path="code-exec" element={<AdminCodeExec />} />
|
||||||
|
<Route path="distributions" element={<DistributionBuilder />} />
|
||||||
|
<Route path="privacy" element={<PrivacySettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
|||||||
@@ -776,4 +776,42 @@ export const evolutionAPI = {
|
|||||||
executeWorkflow: (id: string, inputText: string) =>
|
executeWorkflow: (id: string, inputText: string) =>
|
||||||
post<any>(`/admin/workflows/${id}/execute`, { input_text: inputText }),
|
post<any>(`/admin/workflows/${id}/execute`, { input_text: inputText }),
|
||||||
|
|
||||||
|
|
||||||
|
// Privacy & Local Agent (Phase G5)
|
||||||
|
getPrivacyStats: () => get<any>('/admin/privacy/stats'),
|
||||||
|
getPrivacyConfig: () => get<any>('/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<any>('/admin/privacy/config?' + sp.toString());
|
||||||
|
},
|
||||||
|
switchPrivacyMode: (mode: string) => post<any>('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)),
|
||||||
|
getPrivacyLicenses: (params?: any) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params?.status) sp.append('status', params.status);
|
||||||
|
return get<any>('/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<any>('/admin/privacy/licenses/generate?' + sp.toString());
|
||||||
|
},
|
||||||
|
revokePrivacyLicense: (id: number) => post<any>('/admin/privacy/licenses/' + id + '/revoke'),
|
||||||
|
validatePrivacyLicense: (licenseKey: string) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
sp.append('license_key', licenseKey);
|
||||||
|
return post<any>('/admin/privacy/licenses/validate?' + sp.toString());
|
||||||
|
},
|
||||||
|
getKbSyncStates: () => get<any>('/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<any>('/admin/privacy/kb-sync/trigger?' + sp.toString());
|
||||||
|
},
|
||||||
|
getLocalAgents: () => get<any>('/admin/privacy/local-agents'),
|
||||||
|
getPrivacyUsageStats: () => get<any>('/admin/privacy/usage/stats'),
|
||||||
|
getBridgeConfig: () => get<any>('/admin/privacy/bridge-config'),
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -19,6 +19,7 @@ const adminNavItems = [
|
|||||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
||||||
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
|
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
|
||||||
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
|
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
|
||||||
|
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminIconPaths: Record<string, JSX.Element> = {
|
const adminIconPaths: Record<string, JSX.Element> = {
|
||||||
@@ -35,7 +36,12 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
|||||||
|
|
||||||
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||||
code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
||||||
distro: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,};
|
distro: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
|
||||||
|
privacy: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
<circle cx="12" cy="16" r="1"/>
|
||||||
|
</svg>,};
|
||||||
|
|
||||||
const AdminLayout: React.FC = () => {
|
const AdminLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|||||||
@@ -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<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Stats | null>(null);
|
||||||
|
const [config, setConfig] = useState<PrivacyConfig | null>(null);
|
||||||
|
const [licenses, setLicenses] = useState<License[]>([]);
|
||||||
|
const [kbStates, setKbStates] = useState<KbSyncState[]>([]);
|
||||||
|
const [localAgents, setLocalAgents] = useState<LocalAgent[]>([]);
|
||||||
|
const [usageStats, setUsageStats] = useState<UsageStats | null>(null);
|
||||||
|
const [bridgeConfig, setBridgeConfig] = useState<any>(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<any>(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 (
|
||||||
|
<div style={{ padding: 24, color: '#e0e0e0', background: '#0f0f1a', minHeight: '100vh', fontFamily: 'system-ui' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 24, color: '#fff' }}>🔐 隐私与本地智能体</h1>
|
||||||
|
<p style={{ margin: '4px 0 0', color: '#888', fontSize: 13 }}>管理隐私模式、本地代理、离线许可证和知识库同步</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div style={{ background: '#1a472a', color: '#4ade80', padding: '8px 16px', borderRadius: 6, marginBottom: 16, fontSize: 13 }}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, marginBottom: 24 }}>
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<div key={i} style={{ background: '#1a1a2e', borderRadius: 8, padding: 16, borderLeft: `3px solid ${s.color}` }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#888' }}>{s.label}</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#fff' }}>{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid #2a2a3e', paddingBottom: 0 }}>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setActiveTab(t.key)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: activeTab === t.key ? '#7c3aed' : 'transparent',
|
||||||
|
color: activeTab === t.key ? '#fff' : '#888',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: activeTab === t.key ? 600 : 400,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.icon} {t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div style={{ background: '#1a1a2e', borderRadius: 12, padding: 24 }}>
|
||||||
|
{/* ============ TAB: Privacy Settings ============ */}
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 20px', color: '#fff', fontSize: 18 }}>隐私模式选择</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12, marginBottom: 32 }}>
|
||||||
|
{MODE_OPTIONS.map(m => (
|
||||||
|
<div
|
||||||
|
key={m.value}
|
||||||
|
onClick={() => !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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>{m.icon}</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff' }}>{m.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>{m.desc}</div>
|
||||||
|
{config?.privacy_mode === m.value && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: '#7c3aed', fontWeight: 600 }}>✓ 当前模式</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local LLM Config */}
|
||||||
|
{(config?.privacy_mode === 'local' || config?.privacy_mode === 'hybrid') && (
|
||||||
|
<div style={{ marginBottom: 32 }}>
|
||||||
|
<h3 style={{ margin: '0 0 16px', color: '#fff', fontSize: 16 }}>本地 LLM 配置</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>Provider</label>
|
||||||
|
<select
|
||||||
|
value={config?.local_llm_provider || 'ollama'}
|
||||||
|
onChange={e => updateConfig('local_llm_provider', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="vllm">vLLM</option>
|
||||||
|
<option value="localai">LocalAI</option>
|
||||||
|
<option value="llamacpp">llama.cpp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>Model</label>
|
||||||
|
<input
|
||||||
|
value={config?.local_llm_model || 'qwen2.5:7b'}
|
||||||
|
onChange={e => updateConfig('local_llm_model', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>Endpoint</label>
|
||||||
|
<input
|
||||||
|
value={config?.local_llm_endpoint || 'http://localhost:11434'}
|
||||||
|
onChange={e => updateConfig('local_llm_endpoint', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sync Config */}
|
||||||
|
<div style={{ marginBottom: 32 }}>
|
||||||
|
<h3 style={{ margin: '0 0 16px', color: '#fff', fontSize: 16 }}>同步配置</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>同步频率</label>
|
||||||
|
<select
|
||||||
|
value={config?.sync_frequency || 'daily'}
|
||||||
|
onChange={e => updateConfig('sync_frequency', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{FREQ_OPTIONS.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>数据保留天数</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config?.data_retention_days || 30}
|
||||||
|
onChange={e => 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 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div style={{ background: '#252540', borderRadius: 8, padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, color: '#fff', fontWeight: 600, marginBottom: 8 }}>当前状态</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 13, color: '#aaa' }}>
|
||||||
|
<div>模式: <span style={{ color: '#7c3aed', fontWeight: 600 }}>{config?.privacy_mode || 'cloud'}</span></div>
|
||||||
|
<div>状态: <span style={{ color: config?.status === 'active' ? '#4ade80' : '#f87171' }}>{config?.status || 'active'}</span></div>
|
||||||
|
<div>匿名上报: <span style={{ color: config?.anonymize_usage ? '#4ade80' : '#f87171' }}>{config?.anonymize_usage ? '已启用' : '已禁用'}</span></div>
|
||||||
|
<div>最后同步: <span>{config?.last_sync_at ? formatDate(config.last_sync_at) : '从未'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ TAB: Licenses ============ */}
|
||||||
|
{activeTab === 'licenses' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#fff', fontSize: 18 }}>离线许可证管理</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
placeholder="输入许可证密钥验证..."
|
||||||
|
value={validatingLicense}
|
||||||
|
onChange={e => setValidatingLicense(e.target.value)}
|
||||||
|
style={{ padding: '8px 12px', background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6, fontSize: 13, width: 280 }}
|
||||||
|
/>
|
||||||
|
<button onClick={validateLicenseKey} disabled={loading} style={{
|
||||||
|
padding: '8px 16px', background: '#2563eb', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13
|
||||||
|
}}>验证</button>
|
||||||
|
<button onClick={() => { setShowLicenseModal(true); setNewLicense(null); }} style={{
|
||||||
|
padding: '8px 16px', background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13
|
||||||
|
}}>+ 生成许可证</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* License Table */}
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #2a2a3e' }}>
|
||||||
|
{['ID', '许可证密钥', 'Agent ID', '最大离线天数', '签发时间', '过期时间', '状态', '操作'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontSize: 12, color: '#888', fontWeight: 500 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{licenses.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} style={{ padding: 40, textAlign: 'center', color: '#666' }}>暂无许可证</td></tr>
|
||||||
|
) : licenses.map(l => (
|
||||||
|
<tr key={l.id} style={{ borderBottom: '1px solid #2a2a3e' }}>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#ccc' }}>{l.id}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 12, color: '#aaa', fontFamily: 'monospace', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{l.license_key?.substring(0, 40)}...
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#ccc' }}>{l.agent_id || '-'}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#ccc' }}>{l.max_offline_days} 天</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 12, color: '#888' }}>{formatDate(l.issued_at)}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 12, color: l.status === 'active' ? '#4ade80' : '#f87171' }}>{formatDate(l.expires_at)}</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 10px', borderRadius: 10, fontSize: 11, fontWeight: 600,
|
||||||
|
background: l.status === 'active' ? '#064e3b' : l.status === 'expired' ? '#7f1d1d' : '#4a1d1d',
|
||||||
|
color: l.status === 'active' ? '#4ade80' : l.status === 'expired' ? '#fca5a5' : '#f87171'
|
||||||
|
}}>{l.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
{l.status === 'active' && (
|
||||||
|
<button onClick={() => revokeLicense(l.id)} style={{
|
||||||
|
padding: '4px 12px', background: '#7f1d1d', color: '#fca5a5', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12
|
||||||
|
}}>吊销</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* License Modal */}
|
||||||
|
{showLicenseModal && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||||
|
<div style={{ background: '#1a1a2e', borderRadius: 12, padding: 24, width: 480, maxHeight: '80vh', overflow: 'auto' }}>
|
||||||
|
<h3 style={{ margin: '0 0 20px', color: '#fff' }}>{newLicense ? '✅ 许可证已生成' : '生成离线许可证'}</h3>
|
||||||
|
{newLicense ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ background: '#252540', borderRadius: 8, padding: 16, marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>许可证密钥 (点击复制)</div>
|
||||||
|
<div onClick={() => 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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 13, color: '#aaa' }}>
|
||||||
|
<div>签发时间: {formatDate(newLicense.issued_at ? new Date(newLicense.issued_at * 1000).toISOString() : null)}</div>
|
||||||
|
<div>过期时间: {formatDate(newLicense.expires_at ? new Date(newLicense.expires_at * 1000).toISOString() : null)}</div>
|
||||||
|
<div>离线天数: {newLicense.max_offline_days} 天</div>
|
||||||
|
<div>状态: <span style={{ color: '#4ade80' }}>活跃</span></div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowLicenseModal(false)} style={{
|
||||||
|
marginTop: 16, width: '100%', padding: 10, background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer'
|
||||||
|
}}>关闭</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>Agent (可选)</label>
|
||||||
|
<select value={licenseForm.agent_id} onChange={e => setLicenseForm({ ...licenseForm, agent_id: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6 }}>
|
||||||
|
<option value="">默认</option>
|
||||||
|
{localAgents.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>最大离线天数: {licenseForm.max_offline_days} 天</label>
|
||||||
|
<input type="range" min={1} max={90} value={licenseForm.max_offline_days}
|
||||||
|
onChange={e => setLicenseForm({ ...licenseForm, max_offline_days: parseInt(e.target.value) })}
|
||||||
|
style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowLicenseModal(false)} style={{
|
||||||
|
flex: 1, padding: 10, background: '#333', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer'
|
||||||
|
}}>取消</button>
|
||||||
|
<button onClick={generateLicense} disabled={loading} style={{
|
||||||
|
flex: 1, padding: 10, background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer'
|
||||||
|
}}>{loading ? '生成中...' : '生成'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ TAB: KB Sync ============ */}
|
||||||
|
{activeTab === 'kb_sync' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#fff', fontSize: 18 }}>知识库同步状态</h3>
|
||||||
|
<button onClick={() => setShowSyncModal(true)} style={{
|
||||||
|
padding: '8px 16px', background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13
|
||||||
|
}}>+ 触发同步</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #2a2a3e' }}>
|
||||||
|
{['知识库', '状态', '方向', '文档数', '已同步', '本地版本', '远程版本', '最后同步'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontSize: 12, color: '#888', fontWeight: 500 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{kbStates.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} style={{ padding: 40, textAlign: 'center', color: '#666' }}>暂无同步记录</td></tr>
|
||||||
|
) : kbStates.map(kb => (
|
||||||
|
<tr key={kb.id} style={{ borderBottom: '1px solid #2a2a3e' }}>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#ccc', fontWeight: 500 }}>{kb.kb_name}</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 10px', borderRadius: 10, fontSize: 11, fontWeight: 600,
|
||||||
|
background: kb.sync_status === 'synced' ? '#064e3b' : kb.sync_status === 'syncing' ? '#1e3a5f' : '#4a1d1d',
|
||||||
|
color: kb.sync_status === 'synced' ? '#4ade80' : kb.sync_status === 'syncing' ? '#60a5fa' : '#f87171'
|
||||||
|
}}>{kb.sync_status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#aaa' }}>{kb.sync_direction}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#ccc' }}>{kb.total_docs}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#4ade80' }}>{kb.synced_docs}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#aaa' }}>v{kb.local_version}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 13, color: '#aaa' }}>v{kb.remote_version}</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 12, color: '#888' }}>{formatDate(kb.last_sync_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Sync Modal */}
|
||||||
|
{showSyncModal && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||||
|
<div style={{ background: '#1a1a2e', borderRadius: 12, padding: 24, width: 400 }}>
|
||||||
|
<h3 style={{ margin: '0 0 20px', color: '#fff' }}>触发知识库同步</h3>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>知识库 ID</label>
|
||||||
|
<input type="number" value={syncForm.kb_id} onChange={e => setSyncForm({ ...syncForm, kb_id: e.target.value })}
|
||||||
|
placeholder="输入知识库 ID" style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#888', display: 'block', marginBottom: 4 }}>同步方向</label>
|
||||||
|
<select value={syncForm.direction} onChange={e => setSyncForm({ ...syncForm, direction: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: 10, background: '#252540', color: '#fff', border: '1px solid #3a3a5a', borderRadius: 6 }}>
|
||||||
|
<option value="pull">拉取 (平台→本地)</option>
|
||||||
|
<option value="push">推送 (本地→平台)</option>
|
||||||
|
<option value="bidirectional">双向同步</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowSyncModal(false)} style={{ flex: 1, padding: 10, background: '#333', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>取消</button>
|
||||||
|
<button onClick={triggerSync} disabled={loading} style={{ flex: 1, padding: 10, background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
|
||||||
|
{loading ? '同步中...' : '触发同步'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ TAB: Local Agents ============ */}
|
||||||
|
{activeTab === 'agents' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 20px', color: '#fff', fontSize: 18 }}>本地智能体状态</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
|
||||||
|
{localAgents.length === 0 ? (
|
||||||
|
<div style={{ color: '#666', padding: 40, textAlign: 'center' }}>暂无已发布的 Agent</div>
|
||||||
|
) : localAgents.map(agent => (
|
||||||
|
<div key={agent.id} style={{ background: '#252540', borderRadius: 10, padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: '#fff' }}>{agent.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>ID: {agent.id}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 12px', borderRadius: 10, fontSize: 11, fontWeight: 600,
|
||||||
|
background: agent.privacy_mode === 'local' ? '#2d1f5e' : agent.privacy_mode === 'hybrid' ? '#4a3a0a' : '#1e3a5f',
|
||||||
|
color: agent.privacy_mode === 'local' ? '#a78bfa' : agent.privacy_mode === 'hybrid' ? '#fbbf24' : '#60a5fa'
|
||||||
|
}}>{agent.privacy_mode}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#aaa', marginBottom: 12 }}>
|
||||||
|
{agent.can_run_local ? (
|
||||||
|
<span style={{ color: '#4ade80' }}>✓ 支持本地运行 ({agent.llm_provider || '未配置LLM'})</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#f87171' }}>✗ 仅云端运行</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#666' }}>
|
||||||
|
最后同步: {formatDate(agent.last_sync)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ TAB: Platform Bridge ============ */}
|
||||||
|
{activeTab === 'bridge' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 20px', color: '#fff', fontSize: 18 }}>Platform Bridge 配置</h3>
|
||||||
|
<p style={{ color: '#888', fontSize: 13, marginBottom: 20 }}>
|
||||||
|
将此配置部署到本地 Goose,即可通过 Platform Bridge MCP 扩展连接云端平台。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{bridgeConfig && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
||||||
|
{/* MCP Server Config */}
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: '0 0 12px', color: '#ccc', fontSize: 14 }}>MCP 服务器配置</h4>
|
||||||
|
<pre style={{
|
||||||
|
background: '#0f0f1a', color: '#4ade80', padding: 16, borderRadius: 8, fontSize: 12, fontFamily: 'monospace',
|
||||||
|
overflow: 'auto', maxHeight: 300, whiteSpace: 'pre-wrap'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(bridgeConfig.mcp_server, null, 2)}
|
||||||
|
</pre>
|
||||||
|
<button onClick={() => copyToClipboard(JSON.stringify(bridgeConfig.mcp_server, null, 2))} style={{
|
||||||
|
marginTop: 8, padding: '6px 14px', background: '#2563eb', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12
|
||||||
|
}}>复制配置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync & LLM Config */}
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: '0 0 12px', color: '#ccc', fontSize: 14 }}>同步 & LLM 配置</h4>
|
||||||
|
<pre style={{
|
||||||
|
background: '#0f0f1a', color: '#fbbf24', padding: 16, borderRadius: 8, fontSize: 12, fontFamily: 'monospace',
|
||||||
|
overflow: 'auto', maxHeight: 300, whiteSpace: 'pre-wrap'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify({ sync_config: bridgeConfig.sync_config, local_llm: bridgeConfig.local_llm, privacy_mode: bridgeConfig.privacy_mode }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
<button onClick={() => copyToClipboard(JSON.stringify({
|
||||||
|
sync_config: bridgeConfig.sync_config,
|
||||||
|
local_llm: bridgeConfig.local_llm,
|
||||||
|
privacy_mode: bridgeConfig.privacy_mode
|
||||||
|
}, null, 2))} style={{
|
||||||
|
marginTop: 8, padding: '6px 14px', background: '#ca8a04', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12
|
||||||
|
}}>复制配置</button>
|
||||||
|
|
||||||
|
{/* Deployment Instructions */}
|
||||||
|
<div style={{ marginTop: 20, background: '#252540', borderRadius: 8, padding: 16 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', color: '#ccc', fontSize: 14 }}>🚀 部署步骤</h4>
|
||||||
|
<ol style={{ margin: 0, paddingLeft: 20, fontSize: 12, color: '#aaa', lineHeight: 2 }}>
|
||||||
|
<li>复制 Platform Bridge 代码到本地 <code style={{ background: '#0f0f1a', padding: '2px 6px', borderRadius: 4, color: '#4ade80' }}>~/.goose/extensions/platform-bridge/</code></li>
|
||||||
|
<li>安装依赖: <code style={{ background: '#0f0f1a', padding: '2px 6px', borderRadius: 4, color: '#4ade80' }}>pip install -r requirements.txt</code></li>
|
||||||
|
<li>配置环境变量: 将上方 MCP 服务器配置添加到 Goose</li>
|
||||||
|
<li>启动 Goose: 配置将自动连接到平台</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ TAB: Usage ============ */}
|
||||||
|
{activeTab === 'usage' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 20px', color: '#fff', fontSize: 18 }}>匿名化用量统计</h3>
|
||||||
|
{usageStats && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 24 }}>
|
||||||
|
<div style={{ background: '#252540', borderRadius: 8, padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#888' }}>总上报数</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#fff' }}>{usageStats.total_reports}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#252540', borderRadius: 8, padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#888' }}>匿名用户数</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#2563eb' }}>{usageStats.unique_users}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#252540', borderRadius: 8, padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#888' }}>今日上报</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#4ade80' }}>{usageStats.today_reports}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style={{ margin: '0 0 12px', color: '#ccc', fontSize: 14 }}>按类型分布</h4>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{usageStats.by_type?.map((t, i) => (
|
||||||
|
<div key={i} style={{ background: '#252540', borderRadius: 8, padding: '10px 16px' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#888' }}>{t.type}</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#fff' }}>{t.count}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, background: '#252540', borderRadius: 8, padding: 16 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', color: '#ccc', fontSize: 14 }}>📋 隐私说明</h4>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: '#888', lineHeight: 1.8 }}>
|
||||||
|
所有用量数据在上报前均已通过 SHA-256 哈希匿名化处理。我们仅收集聚合统计数据,不包含任何可识别个人身份的信息。
|
||||||
|
数据包括:执行次数、模型调用次数、知识库查询次数、Agent 运行时统计等。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in new issue
Block a user