tripocli v2.0.0:Tripo AI 3D 生成与加工 CLI(面向 Agent 与开发者,纯 Python 标准库、零第三方依赖)
覆盖文生 3D、图生 3D、多视图、批量生成,以及重拓扑、绑定、动画、智能分割、 纹理、PBR、智能 UV、部件补全、格式导出与下载。 代码组织:一个功能一个模块(tripo/features/),入口薄壳 tripocli.py。
This commit is contained in:
commit
977f97995a
60 files changed
+5622
No files matched your search
+253
@@ -0,0 +1,253 @@
|
||||
"""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}/"
|
||||
Reference in new issue
Block a user