Files
tripocli/tripo/server.py
T
Mrpzx 977f97995a tripocli v2.0.0:Tripo AI 3D 生成与加工 CLI(面向 Agent 与开发者,纯 Python 标准库、零第三方依赖)
覆盖文生 3D、图生 3D、多视图、批量生成,以及重拓扑、绑定、动画、智能分割、
纹理、PBR、智能 UV、部件补全、格式导出与下载。

代码组织:一个功能一个模块(tripo/features/),入口薄壳 tripocli.py。
2026-10-13 19:22:54 +08:00

254 lines
9.4 KiB
Python

"""Local account-management service (``tripocli serve``).
Serves a small web page plus a JSON API so cookies can be added, updated and
inspected from a browser instead of the terminal:
GET / the page
GET /api/accounts list accounts (identity, session expiry, credits)
POST /api/accounts add/update an account {name?, cookie}
POST /api/accounts/<name>/activate make it the active account
POST /api/accounts/<name>/refresh re-validate the cookie and re-mint the JWT
DELETE /api/accounts/<name> remove an account
The page never receives a full cookie — only a masked form. The server binds to
loopback by default because whoever can reach it can act as the signed-in user.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
from . import auth
from .accounts import Accounts
from .client import TripoClient
from .config import Config
from .errors import TripoAPIError
PAGE = Path(__file__).parent / "web" / "accounts.html"
CREDIT_CACHE_SECONDS = 30
def _mask(value, keep=8):
if not value:
return None
return f"{value[:keep]}…{value[-4:]}" if len(value) > keep + 6 else "***"
class AccountService:
"""Account store plus a short-lived credits cache for the list view."""
def __init__(self, accounts=None, config=None):
self.accounts = accounts or Accounts()
self.config = config or Config()
self._lock = threading.Lock()
self._cache = {} # name -> (timestamp, payload)
# --- helpers ---
def _client(self, name=None):
return TripoClient(account=name, accounts=self.accounts, config=self.config)
def _live_status(self, name):
"""Credits/membership for one account, cached briefly to keep the page snappy."""
cached = self._cache.get(name)
if cached and time.time() - cached[0] < CREDIT_CACHE_SECONDS:
return cached[1]
status = {"total_credit": None, "expiring_credit": None, "expiring_date": None,
"member_type": None, "member_valid_until": None, "token_expires_at": None,
"error": None}
try:
client = self._client(name)
client.ensure_credentials()
profile = client.payment_profile() or {}
wallet = profile.get("wallet") or {}
member = profile.get("member") or {}
status.update({
"total_credit": wallet.get("total_credit"),
"expiring_credit": wallet.get("expiring_credit"),
"expiring_date": wallet.get("expiring_date"),
"member_type": member.get("type"),
"member_valid_until": member.get("valid_until"),
"token_expires_at": _iso(client.token),
})
except Exception as error:
status["error"] = str(error)[:200]
self._cache[name] = (time.time(), status)
return status
def list_accounts(self):
active = self.accounts.active_name()
result = []
for name in self.accounts.names():
record = self.accounts.data["accounts"][name]
item = {
"name": name,
"active": name == active,
"email": record.get("email"),
"user_id": record.get("user_id"),
"session_expires_at": record.get("session_expires_at"),
"cookie": _mask(record.get("cookie")),
}
item.update(self._live_status(name))
result.append(item)
return {"active": active, "accounts": result}
def add_account(self, name, cookie):
if not cookie:
raise ValueError("cookie 不能为空")
token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id)
try:
user_id, email = auth.fetch_identity(self.config.api_base, cookie, self.config.device_id)
except Exception:
user_id, email = None, None
name = (name or email or user_id or "default").strip()
with self._lock:
self.accounts.add(name, auth.normalize_cookie(cookie), token=token,
session_expires_at=session_expires, user_id=user_id, email=email)
self._cache.pop(name, None)
return {"name": name, "email": email, "user_id": user_id, "session_expires_at": session_expires}
def activate(self, name):
with self._lock:
if not self.accounts.exists(name):
raise KeyError(name)
self.accounts.use(name)
return {"active": name}
def remove(self, name):
with self._lock:
if not self.accounts.exists(name):
raise KeyError(name)
self.accounts.remove(name)
self._cache.pop(name, None)
return {"removed": name, "active": self.accounts.active_name()}
def refresh(self, name):
with self._lock:
_, record = self.accounts.get(name)
if not record:
raise KeyError(name)
cookie = record.get("cookie")
if not cookie:
raise ValueError(f"账号 {name} 没有 cookie")
token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id)
with self._lock:
self.accounts.update_token(name, token, session_expires)
self._cache.pop(name, None)
return {"name": name, "token_expires_at": _iso(token), "session_expires_at": session_expires}
def _iso(token):
exp = auth.decode_jwt_payload(token or "").get("exp")
if not exp:
return None
return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(int(exp)))
class Handler(BaseHTTPRequestHandler):
server_version = "tripocli"
service: AccountService = None
# --- plumbing ---
def log_message(self, fmt, *args):
print(f"[serve] {self.address_string()} {fmt % args}")
def _send(self, status, payload, content_type="application/json; charset=utf-8"):
body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _error(self, status, message):
self._send(status, {"error": str(message)})
def _read_json(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except json.JSONDecodeError:
raise ValueError("请求体不是合法 JSON")
# --- routing ---
def _segments(self):
path = urlparse(self.path).path
return [unquote(part) for part in path.strip("/").split("/") if part]
def do_GET(self):
parts = self._segments()
if not parts:
try:
self._send(200, PAGE.read_bytes(), "text/html; charset=utf-8")
except OSError as error:
self._error(500, f"页面文件读取失败: {error}")
return
if parts == ["api", "accounts"]:
self._send(200, self.service.list_accounts())
return
if parts == ["api", "health"]:
self._send(200, {"ok": True})
return
self._error(404, "not found")
def do_POST(self):
parts = self._segments()
try:
if parts == ["api", "accounts"]:
body = self._read_json()
self._send(200, self.service.add_account(body.get("name", ""), body.get("cookie", "")))
return
if len(parts) == 4 and parts[:2] == ["api", "accounts"]:
name, action = parts[2], parts[3]
if action == "activate":
self._send(200, self.service.activate(name))
return
if action == "refresh":
self._send(200, self.service.refresh(name))
return
except KeyError as error:
self._error(404, f"账号不存在: {error.args[0]}")
return
except (ValueError, TripoAPIError) as error:
self._error(400, error)
return
except Exception as error:
self._error(500, error)
return
self._error(404, "not found")
def do_DELETE(self):
parts = self._segments()
if len(parts) == 3 and parts[:2] == ["api", "accounts"]:
try:
self._send(200, self.service.remove(parts[2]))
except KeyError as error:
self._error(404, f"账号不存在: {error.args[0]}")
except Exception as error:
self._error(500, error)
return
self._error(404, "not found")
def create_server(host="127.0.0.1", port=8765, service=None):
handler = type("BoundHandler", (Handler,), {"service": service or AccountService()})
return ThreadingHTTPServer((host, port), handler)
def serve(host="127.0.0.1", port=8765):
httpd = create_server(host, port)
actual_port = httpd.server_address[1]
return httpd, f"http://{host}:{actual_port}/"