monkeycode-sdk 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- monkeycode/__init__.py +58 -0
- monkeycode/client.py +2141 -0
- monkeycode/consts.py +29 -0
- monkeycode/errors.py +30 -0
- monkeycode/links.py +138 -0
- monkeycode/models.py +227 -0
- monkeycode/pow.py +65 -0
- monkeycode/session.py +140 -0
- monkeycode/stream.py +381 -0
- monkeycode_sdk-1.7.0.dist-info/METADATA +93 -0
- monkeycode_sdk-1.7.0.dist-info/RECORD +14 -0
- monkeycode_sdk-1.7.0.dist-info/WHEEL +5 -0
- monkeycode_sdk-1.7.0.dist-info/licenses/LICENSE +21 -0
- monkeycode_sdk-1.7.0.dist-info/top_level.txt +1 -0
monkeycode/client.py
ADDED
|
@@ -0,0 +1,2141 @@
|
|
|
1
|
+
"""MonkeyCode 云平台 REST API 客户端。
|
|
2
|
+
|
|
3
|
+
基于 httpx,登录后自动携带会话 Cookie(monkeycode_ai_session)。
|
|
4
|
+
支持密码登录(MD5 + 可选验证码)、枚举环境、创建任务、轮询结果。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from monkeycode.consts import CliName, TaskSubType, TaskType
|
|
19
|
+
from monkeycode.errors import (
|
|
20
|
+
APIError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
CaptchaRequiredError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
TaskError,
|
|
25
|
+
)
|
|
26
|
+
from monkeycode.models import (
|
|
27
|
+
APIKeyInfo,
|
|
28
|
+
CaptchaChallenge,
|
|
29
|
+
CaptchaVerification,
|
|
30
|
+
RepoReq,
|
|
31
|
+
Task,
|
|
32
|
+
TaskExtraConfig,
|
|
33
|
+
TaskStreamChunk,
|
|
34
|
+
VMResource,
|
|
35
|
+
)
|
|
36
|
+
from monkeycode.links import check_url_status, collect_preview_links
|
|
37
|
+
from monkeycode.pow import solve
|
|
38
|
+
from monkeycode.stream import (
|
|
39
|
+
ControlStream,
|
|
40
|
+
JoinTerminalStream,
|
|
41
|
+
TaskStream,
|
|
42
|
+
TerminalStream,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
DEFAULT_BASE_URL = "https://monkeycode-ai.com"
|
|
46
|
+
SESSION_COOKIE = "monkeycode_ai_session"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _md5(text: str) -> str:
|
|
50
|
+
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_RETRYABLE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
54
|
+
_MAX_RETRIES = 3
|
|
55
|
+
_RETRY_BASE_DELAY = 1.0
|
|
56
|
+
|
|
57
|
+
_FRIENDLY_ERRORS: dict[int, str] = {
|
|
58
|
+
10606: "邮箱或密码错误",
|
|
59
|
+
10205: "云端宿主不可调度,请到控制台检查云端环境",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _friendly_error(code: int | str | None, message: str) -> str:
|
|
64
|
+
"""将已知业务错误码替换为更友好的提示。"""
|
|
65
|
+
try:
|
|
66
|
+
hint = _FRIENDLY_ERRORS.get(int(code)) # type: ignore[arg-type]
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return message
|
|
69
|
+
if hint:
|
|
70
|
+
return f"{message}(提示:{hint})"
|
|
71
|
+
return message
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MonkeyCodeClient:
|
|
75
|
+
"""MonkeyCode 云平台客户端。
|
|
76
|
+
|
|
77
|
+
示例:
|
|
78
|
+
client = MonkeyCodeClient(email="...", password="...")
|
|
79
|
+
client.login()
|
|
80
|
+
task = client.create_task("修复登录接口异常", repo=RepoReq(repo_url="..."), ...)
|
|
81
|
+
result = client.wait_task(task.id)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
email: str | None = None,
|
|
87
|
+
password: str | None = None,
|
|
88
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
89
|
+
timeout: float = 30.0,
|
|
90
|
+
password_md5: bool = False,
|
|
91
|
+
session_path: str | None = None,
|
|
92
|
+
auto_relogin: bool = True,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.email = email
|
|
95
|
+
self._password = password or ""
|
|
96
|
+
self._password_md5_mode = password_md5
|
|
97
|
+
self.base_url = base_url.rstrip("/")
|
|
98
|
+
self._session_path = session_path
|
|
99
|
+
self._auto_relogin = auto_relogin
|
|
100
|
+
self._relogging = False
|
|
101
|
+
self._client = httpx.Client(
|
|
102
|
+
base_url=self.base_url,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
follow_redirects=True,
|
|
105
|
+
)
|
|
106
|
+
self._logged_in = False
|
|
107
|
+
self._load_session()
|
|
108
|
+
|
|
109
|
+
# ---------- 认证 ----------
|
|
110
|
+
|
|
111
|
+
def login(self, captcha_token: str | None = None) -> dict[str, Any]:
|
|
112
|
+
"""使用邮箱 + 密码登录,自动处理 PoW 验证码。
|
|
113
|
+
|
|
114
|
+
生产平台 monkeycode-ai.com 使用 bcrypt 校验,需传**明文密码**;
|
|
115
|
+
早期/部分私有化部署可能要求 MD5,可用 password_md5=True 兼容。
|
|
116
|
+
|
|
117
|
+
云平台强制启用验证码(SHA-256 前缀碰撞),本方法会自动:
|
|
118
|
+
challenge -> 本地求解 -> redeem -> 携带 captcha_token 登录。
|
|
119
|
+
也可以显式传入 captcha_token 跳过自动求解。
|
|
120
|
+
登录成功后自动保存会话 Cookie。
|
|
121
|
+
"""
|
|
122
|
+
if not self.email or not self._password:
|
|
123
|
+
raise AuthenticationError("缺少邮箱或密码,请在构造客户端时传入。")
|
|
124
|
+
try:
|
|
125
|
+
return self._login_request(captcha_token)
|
|
126
|
+
except CaptchaRequiredError:
|
|
127
|
+
if captcha_token:
|
|
128
|
+
raise
|
|
129
|
+
token = self._solve_captcha()
|
|
130
|
+
return self._login_request(token)
|
|
131
|
+
|
|
132
|
+
def _login_request(self, captcha_token: str | None) -> dict[str, Any]:
|
|
133
|
+
"""发送登录请求。"""
|
|
134
|
+
password = _md5(self._password) if self._password_md5_mode else self._password
|
|
135
|
+
body: dict[str, Any] = {
|
|
136
|
+
"email": self.email,
|
|
137
|
+
"password": password,
|
|
138
|
+
}
|
|
139
|
+
if captcha_token:
|
|
140
|
+
body["captcha_token"] = captcha_token
|
|
141
|
+
try:
|
|
142
|
+
data = self._request("POST", "/api/v1/users/password-login", json=body)
|
|
143
|
+
except AuthenticationError as exc:
|
|
144
|
+
if exc.code == 403 and not captcha_token:
|
|
145
|
+
raise CaptchaRequiredError(
|
|
146
|
+
"登录需要验证码。",
|
|
147
|
+
code=403,
|
|
148
|
+
) from exc
|
|
149
|
+
raise
|
|
150
|
+
self._logged_in = True
|
|
151
|
+
self._save_session()
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
def _solve_captcha(self) -> str:
|
|
155
|
+
"""自动完成 PoW 验证码:challenge -> 本地求解 -> redeem。"""
|
|
156
|
+
challenge = self.create_captcha()
|
|
157
|
+
c = int(challenge.raw["challenge"]["c"])
|
|
158
|
+
s = int(challenge.raw["challenge"]["s"])
|
|
159
|
+
d = int(challenge.raw["challenge"]["d"])
|
|
160
|
+
solutions = solve(challenge.token, c, s, d)
|
|
161
|
+
result = self.redeem_captcha(challenge.token, solutions)
|
|
162
|
+
if not result.success:
|
|
163
|
+
raise AuthenticationError(f"验证码兑换失败:{result.message}")
|
|
164
|
+
return result.token
|
|
165
|
+
|
|
166
|
+
def create_captcha(self) -> CaptchaChallenge:
|
|
167
|
+
"""创建验证码质询。"""
|
|
168
|
+
data = self._request(
|
|
169
|
+
"POST", "/api/v1/public/captcha/challenge", json={}, auth_required=False
|
|
170
|
+
)
|
|
171
|
+
return CaptchaChallenge(
|
|
172
|
+
token=str(data.get("token") or ""),
|
|
173
|
+
expires=int(data.get("expires") or 0),
|
|
174
|
+
raw=data,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def redeem_captcha(self, token: str, solutions: list[int]) -> CaptchaVerification:
|
|
178
|
+
"""提交验证码答案,兑换登录用的 captcha_token。"""
|
|
179
|
+
data = self._request(
|
|
180
|
+
"POST",
|
|
181
|
+
"/api/v1/public/captcha/redeem",
|
|
182
|
+
json={"token": token, "solutions": solutions},
|
|
183
|
+
auth_required=False,
|
|
184
|
+
)
|
|
185
|
+
return CaptchaVerification(
|
|
186
|
+
success=bool(data.get("success")),
|
|
187
|
+
token=str(data.get("token") or ""),
|
|
188
|
+
message=str(data.get("message") or ""),
|
|
189
|
+
expires=int(data.get("expires") or 0),
|
|
190
|
+
raw=data,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# ---------- 用户状态 / 退出登录 ----------
|
|
194
|
+
|
|
195
|
+
def get_user_status(self) -> Any:
|
|
196
|
+
"""获取当前登录用户身份/状态(whoami 底层接口)。"""
|
|
197
|
+
return self._request("GET", "/api/v1/users/status")
|
|
198
|
+
|
|
199
|
+
def whoami(self) -> Any:
|
|
200
|
+
"""获取当前登录用户信息(get_user_status 别名)。"""
|
|
201
|
+
return self.get_user_status()
|
|
202
|
+
|
|
203
|
+
def is_logged_in(self) -> bool:
|
|
204
|
+
"""是否已有登录态(含从 session_path 加载的持久化会话)。"""
|
|
205
|
+
return self._logged_in
|
|
206
|
+
|
|
207
|
+
def logout(self) -> Any:
|
|
208
|
+
"""退出当前用户会话(清除云端登录态并删除本地会话文件)。"""
|
|
209
|
+
result = self._request("POST", "/api/v1/users/logout")
|
|
210
|
+
self._logged_in = False
|
|
211
|
+
self._clear_session()
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
def team_logout(self) -> Any:
|
|
215
|
+
"""退出团队管理员会话(清除云端登录态并删除本地会话文件)。"""
|
|
216
|
+
result = self._request("POST", "/api/v1/teams/users/logout")
|
|
217
|
+
self._logged_in = False
|
|
218
|
+
self._clear_session()
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
# ---------- 会话持久化 ----------
|
|
222
|
+
|
|
223
|
+
def _save_session(self) -> None:
|
|
224
|
+
"""将当前会话 Cookie 持久化到 session_path(可选)。"""
|
|
225
|
+
if not self._session_path:
|
|
226
|
+
return
|
|
227
|
+
try:
|
|
228
|
+
cookies = {
|
|
229
|
+
name: value for name, value in self._client.cookies.items()
|
|
230
|
+
}
|
|
231
|
+
path = Path(self._session_path)
|
|
232
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
path.write_text(json.dumps(cookies), encoding="utf-8")
|
|
234
|
+
try:
|
|
235
|
+
os.chmod(path, 0o600)
|
|
236
|
+
except OSError:
|
|
237
|
+
pass
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
def _load_session(self) -> None:
|
|
242
|
+
"""从 session_path 加载持久化 Cookie(可选)。"""
|
|
243
|
+
if not self._session_path:
|
|
244
|
+
return
|
|
245
|
+
try:
|
|
246
|
+
path = Path(self._session_path)
|
|
247
|
+
if not path.exists():
|
|
248
|
+
return
|
|
249
|
+
cookies = json.loads(path.read_text(encoding="utf-8"))
|
|
250
|
+
except (OSError, ValueError):
|
|
251
|
+
return
|
|
252
|
+
for name, value in cookies.items():
|
|
253
|
+
self._client.cookies.set(name, value)
|
|
254
|
+
if cookies:
|
|
255
|
+
self._logged_in = True
|
|
256
|
+
|
|
257
|
+
def _clear_session(self) -> None:
|
|
258
|
+
"""删除本地会话文件(退出登录时调用)。"""
|
|
259
|
+
if not self._session_path:
|
|
260
|
+
return
|
|
261
|
+
try:
|
|
262
|
+
Path(self._session_path).unlink(missing_ok=True)
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
def team_list_conversations(self) -> Any:
|
|
267
|
+
"""获取团队 agent 会话列表(管理端)。"""
|
|
268
|
+
return self._request("GET", "/api/v1/teams/conversations")
|
|
269
|
+
|
|
270
|
+
# ---------- 服务器配置 / 团队登录 ----------
|
|
271
|
+
|
|
272
|
+
def get_server_config(self) -> dict[str, Any]:
|
|
273
|
+
"""获取服务器公开配置(无需登录)。
|
|
274
|
+
|
|
275
|
+
返回 data 通常包含 edition / region / captcha_enabled 等字段,
|
|
276
|
+
自托管部署可通过该接口确认是否启用验证码。
|
|
277
|
+
"""
|
|
278
|
+
return self._request("GET", "/api/v1/server/config", auth_required=False)
|
|
279
|
+
|
|
280
|
+
def team_login(
|
|
281
|
+
self,
|
|
282
|
+
email: str | None = None,
|
|
283
|
+
password: str | None = None,
|
|
284
|
+
captcha_token: str | None = None,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
"""团队管理员登录。
|
|
287
|
+
|
|
288
|
+
使用 POST /api/v1/teams/users/login,成功后保存团队会话 Cookie
|
|
289
|
+
monkeycode_ai_team_session。邮箱/密码缺省时使用构造客户端时的凭证。
|
|
290
|
+
与个人登录一样,云平台强制 PoW 验证码时自动求解。
|
|
291
|
+
"""
|
|
292
|
+
mail = email or self.email
|
|
293
|
+
pwd = password or self._password
|
|
294
|
+
if not mail or not pwd:
|
|
295
|
+
raise AuthenticationError("缺少邮箱或密码,请在构造客户端时传入。")
|
|
296
|
+
try:
|
|
297
|
+
return self._team_login_request(mail, pwd, captcha_token)
|
|
298
|
+
except CaptchaRequiredError:
|
|
299
|
+
if captcha_token:
|
|
300
|
+
raise
|
|
301
|
+
token = self._solve_captcha()
|
|
302
|
+
return self._team_login_request(mail, pwd, token)
|
|
303
|
+
|
|
304
|
+
def _team_login_request(
|
|
305
|
+
self, email: str, password: str, captcha_token: str | None
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
"""发送团队登录请求。"""
|
|
308
|
+
pwd = _md5(password) if self._password_md5_mode else password
|
|
309
|
+
body: dict[str, Any] = {
|
|
310
|
+
"email": email,
|
|
311
|
+
"password": pwd,
|
|
312
|
+
}
|
|
313
|
+
if captcha_token:
|
|
314
|
+
body["captcha_token"] = captcha_token
|
|
315
|
+
try:
|
|
316
|
+
return self._request("POST", "/api/v1/teams/users/login", json=body)
|
|
317
|
+
except AuthenticationError as exc:
|
|
318
|
+
if exc.code == 403 and not captcha_token:
|
|
319
|
+
raise CaptchaRequiredError(
|
|
320
|
+
"团队登录需要验证码。",
|
|
321
|
+
code=403,
|
|
322
|
+
) from exc
|
|
323
|
+
raise
|
|
324
|
+
|
|
325
|
+
# ---------- 枚举环境 ----------
|
|
326
|
+
|
|
327
|
+
def list_hosts(self) -> list[dict[str, Any]]:
|
|
328
|
+
"""获取可用的云端宿主/环境列表(返回 data.hosts)。"""
|
|
329
|
+
data = self._request("GET", "/api/v1/users/hosts")
|
|
330
|
+
if isinstance(data, dict):
|
|
331
|
+
return data.get("hosts") or []
|
|
332
|
+
return data or []
|
|
333
|
+
|
|
334
|
+
# ---------- 宿主机管理 ----------
|
|
335
|
+
|
|
336
|
+
def create_host(self, **fields: Any) -> Any:
|
|
337
|
+
"""注册宿主机。
|
|
338
|
+
|
|
339
|
+
字段以关键字参数传入(如 name、host_id、api_key、备注等),
|
|
340
|
+
与平台前端注册表单保持一致。
|
|
341
|
+
"""
|
|
342
|
+
return self._request("POST", "/api/v1/users/hosts", json=fields)
|
|
343
|
+
|
|
344
|
+
def update_host(self, host_id: str, **fields: Any) -> Any:
|
|
345
|
+
"""更新宿主机配置。"""
|
|
346
|
+
return self._request(
|
|
347
|
+
"PUT", f"/api/v1/users/hosts/{host_id}", json=fields
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def delete_host(self, host_id: str) -> Any:
|
|
351
|
+
"""删除宿主机。"""
|
|
352
|
+
return self._request("DELETE", f"/api/v1/users/hosts/{host_id}")
|
|
353
|
+
|
|
354
|
+
def get_host_install_command(self) -> Any:
|
|
355
|
+
"""获取宿主机安装命令(用于接入新宿主机)。"""
|
|
356
|
+
return self._request("GET", "/api/v1/users/hosts/install-command")
|
|
357
|
+
|
|
358
|
+
def list_images(self) -> list[dict[str, Any]]:
|
|
359
|
+
"""获取可用的镜像列表(返回 data.images)。"""
|
|
360
|
+
data = self._request("GET", "/api/v1/users/images")
|
|
361
|
+
if isinstance(data, dict):
|
|
362
|
+
return data.get("images") or []
|
|
363
|
+
return data or []
|
|
364
|
+
|
|
365
|
+
def list_models(self) -> list[dict[str, Any]]:
|
|
366
|
+
"""获取可用的模型列表(返回 data.models)。"""
|
|
367
|
+
data = self._request("GET", "/api/v1/users/models")
|
|
368
|
+
if isinstance(data, dict):
|
|
369
|
+
return data.get("models") or []
|
|
370
|
+
return data or []
|
|
371
|
+
|
|
372
|
+
def list_available_models(self) -> Any:
|
|
373
|
+
"""获取可用模型列表(仅查询,不做 CRUD)。"""
|
|
374
|
+
return self._request("GET", "/api/v1/users/models/available")
|
|
375
|
+
|
|
376
|
+
# ---------- AI 大模型管理 ----------
|
|
377
|
+
|
|
378
|
+
def create_model(self, **fields: Any) -> Any:
|
|
379
|
+
"""创建模型配置。
|
|
380
|
+
|
|
381
|
+
字段以关键字参数传入(如 name、provider、api_key、base_url 等),
|
|
382
|
+
与平台前端表单字段保持一致。
|
|
383
|
+
"""
|
|
384
|
+
return self._request("POST", "/api/v1/users/models", json=fields)
|
|
385
|
+
|
|
386
|
+
def update_model(self, model_id: str, **fields: Any) -> Any:
|
|
387
|
+
"""更新模型配置(如 API Key、备注、供应商等)。"""
|
|
388
|
+
return self._request(
|
|
389
|
+
"PUT", f"/api/v1/users/models/{model_id}", json=fields
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def delete_model(self, model_id: str) -> Any:
|
|
393
|
+
"""删除模型配置。"""
|
|
394
|
+
return self._request("DELETE", f"/api/v1/users/models/{model_id}")
|
|
395
|
+
|
|
396
|
+
def list_model_providers(
|
|
397
|
+
self,
|
|
398
|
+
provider: str,
|
|
399
|
+
base_url: str,
|
|
400
|
+
api_key: str,
|
|
401
|
+
api_header: str | None = None,
|
|
402
|
+
) -> Any:
|
|
403
|
+
"""列出模型供应商的模型列表。
|
|
404
|
+
|
|
405
|
+
provider 取值:SiliconFlow / OpenAI / Ollama / DeepSeek /
|
|
406
|
+
Moonshot / AzureOpenAI / BaiZhiCloud / Hunyuan / BaiLian /
|
|
407
|
+
Volcengine / Gemini / Other。
|
|
408
|
+
"""
|
|
409
|
+
params: dict[str, Any] = {
|
|
410
|
+
"provider": provider,
|
|
411
|
+
"base_url": base_url,
|
|
412
|
+
"api_key": api_key,
|
|
413
|
+
}
|
|
414
|
+
if api_header:
|
|
415
|
+
params["api_header"] = api_header
|
|
416
|
+
return self._request(
|
|
417
|
+
"GET", "/api/v1/users/models/providers", params=params
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def model_health_check(self, model_ids: list[str]) -> Any:
|
|
421
|
+
"""批量执行模型健康检查。
|
|
422
|
+
|
|
423
|
+
model_ids 为模型 ID 列表,返回各模型连通性/鉴权结果。
|
|
424
|
+
"""
|
|
425
|
+
return self._request(
|
|
426
|
+
"POST",
|
|
427
|
+
"/api/v1/users/models/health-check",
|
|
428
|
+
json={"model_ids": model_ids},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def get_model_health_check(self, model_id: str) -> Any:
|
|
432
|
+
"""获取单个模型健康检查结果。"""
|
|
433
|
+
return self._request(
|
|
434
|
+
"GET", f"/api/v1/users/models/{model_id}/health-check"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def list_projects(
|
|
438
|
+
self, cursor: str | None = None, limit: int | None = None
|
|
439
|
+
) -> list[dict[str, Any]]:
|
|
440
|
+
"""获取项目(仓库)列表,支持分页。"""
|
|
441
|
+
params: dict[str, Any] = {}
|
|
442
|
+
if cursor:
|
|
443
|
+
params["cursor"] = cursor
|
|
444
|
+
if limit is not None:
|
|
445
|
+
params["limit"] = limit
|
|
446
|
+
data = self._request("GET", "/api/v1/users/projects", params=params)
|
|
447
|
+
if isinstance(data, dict):
|
|
448
|
+
return data.get("projects") or []
|
|
449
|
+
return data or []
|
|
450
|
+
|
|
451
|
+
# ---------- 分页 helper ----------
|
|
452
|
+
|
|
453
|
+
def paginate(
|
|
454
|
+
self,
|
|
455
|
+
fetcher,
|
|
456
|
+
extract=None,
|
|
457
|
+
page_size: int = 20,
|
|
458
|
+
max_pages: int = 100,
|
|
459
|
+
):
|
|
460
|
+
"""通用分页迭代器。
|
|
461
|
+
|
|
462
|
+
fetcher(page, size) 返回一页原始数据;
|
|
463
|
+
extract(raw) 提取当前页条目列表(默认 raw 本身是列表)。
|
|
464
|
+
当某页条目数小于 page_size 时结束。
|
|
465
|
+
"""
|
|
466
|
+
extract = extract or (lambda raw: raw if isinstance(raw, list) else [])
|
|
467
|
+
for page in range(1, max_pages + 1):
|
|
468
|
+
raw = fetcher(page, page_size)
|
|
469
|
+
items = extract(raw) or []
|
|
470
|
+
for item in items:
|
|
471
|
+
yield item
|
|
472
|
+
if len(items) < page_size:
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
def iter_tasks(
|
|
476
|
+
self,
|
|
477
|
+
page_size: int = 20,
|
|
478
|
+
max_pages: int = 100,
|
|
479
|
+
**filters: Any,
|
|
480
|
+
):
|
|
481
|
+
"""分页迭代任务列表。
|
|
482
|
+
|
|
483
|
+
额外筛选参数透传给 list_tasks(status/project_id 等)。
|
|
484
|
+
"""
|
|
485
|
+
return self.paginate(
|
|
486
|
+
lambda page, size: self.list_tasks(
|
|
487
|
+
page=page, size=size, **filters
|
|
488
|
+
),
|
|
489
|
+
extract=lambda raw: raw.get("tasks")
|
|
490
|
+
if isinstance(raw, dict)
|
|
491
|
+
else (raw or []),
|
|
492
|
+
page_size=page_size,
|
|
493
|
+
max_pages=max_pages,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def iter_projects(
|
|
497
|
+
self,
|
|
498
|
+
page_size: int = 20,
|
|
499
|
+
max_pages: int = 100,
|
|
500
|
+
**filters: Any,
|
|
501
|
+
):
|
|
502
|
+
"""分页迭代项目列表。
|
|
503
|
+
|
|
504
|
+
额外筛选参数透传给 list_projects。
|
|
505
|
+
"""
|
|
506
|
+
return self.paginate(
|
|
507
|
+
lambda page, size: self.list_projects(
|
|
508
|
+
limit=size, **filters
|
|
509
|
+
),
|
|
510
|
+
extract=lambda raw: raw if isinstance(raw, list) else (raw or []),
|
|
511
|
+
page_size=page_size,
|
|
512
|
+
max_pages=max_pages,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def create_project(
|
|
516
|
+
self,
|
|
517
|
+
name: str,
|
|
518
|
+
repo_url: str,
|
|
519
|
+
platform: str = "github",
|
|
520
|
+
git_identity_id: str | None = None,
|
|
521
|
+
image_id: str | None = None,
|
|
522
|
+
description: str | None = None,
|
|
523
|
+
env_variables: dict[str, Any] | None = None,
|
|
524
|
+
) -> Any:
|
|
525
|
+
"""创建项目(仓库关联)。"""
|
|
526
|
+
body: dict[str, Any] = {
|
|
527
|
+
"name": name,
|
|
528
|
+
"repo_url": repo_url,
|
|
529
|
+
"platform": platform,
|
|
530
|
+
}
|
|
531
|
+
if git_identity_id:
|
|
532
|
+
body["git_identity_id"] = git_identity_id
|
|
533
|
+
if image_id:
|
|
534
|
+
body["image_id"] = image_id
|
|
535
|
+
if description:
|
|
536
|
+
body["description"] = description
|
|
537
|
+
if env_variables:
|
|
538
|
+
body["env_variables"] = env_variables
|
|
539
|
+
return self._request("POST", "/api/v1/users/projects", json=body)
|
|
540
|
+
|
|
541
|
+
def get_project_tree(
|
|
542
|
+
self,
|
|
543
|
+
project_id: str,
|
|
544
|
+
path: str = "",
|
|
545
|
+
ref: str | None = None,
|
|
546
|
+
recursive: bool | None = None,
|
|
547
|
+
) -> Any:
|
|
548
|
+
"""获取项目仓库树。"""
|
|
549
|
+
params: dict[str, Any] = {"path": path}
|
|
550
|
+
if ref:
|
|
551
|
+
params["ref"] = ref
|
|
552
|
+
if recursive is not None:
|
|
553
|
+
params["recursive"] = recursive
|
|
554
|
+
return self._request(
|
|
555
|
+
"GET", f"/api/v1/users/projects/{project_id}/tree", params=params
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def get_project_file(
|
|
559
|
+
self, project_id: str, path: str, ref: str | None = None
|
|
560
|
+
) -> Any:
|
|
561
|
+
"""获取项目文件内容。"""
|
|
562
|
+
params: dict[str, Any] = {"path": path}
|
|
563
|
+
if ref:
|
|
564
|
+
params["ref"] = ref
|
|
565
|
+
return self._request(
|
|
566
|
+
"GET", f"/api/v1/users/projects/{project_id}/tree/blob", params=params
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def list_project_issues(
|
|
570
|
+
self, project_id: str, cursor: str | None = None, limit: int | None = None
|
|
571
|
+
) -> Any:
|
|
572
|
+
"""获取项目 Issue 列表。"""
|
|
573
|
+
params: dict[str, Any] = {}
|
|
574
|
+
if cursor:
|
|
575
|
+
params["cursor"] = cursor
|
|
576
|
+
if limit is not None:
|
|
577
|
+
params["limit"] = limit
|
|
578
|
+
return self._request(
|
|
579
|
+
"GET", f"/api/v1/users/projects/{project_id}/issues", params=params
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def create_project_issue(
|
|
583
|
+
self,
|
|
584
|
+
project_id: str,
|
|
585
|
+
title: str,
|
|
586
|
+
assignee_id: str | None = None,
|
|
587
|
+
priority: int | None = None,
|
|
588
|
+
requirement_document: str | None = None,
|
|
589
|
+
) -> Any:
|
|
590
|
+
"""创建项目 Issue。"""
|
|
591
|
+
body: dict[str, Any] = {"title": title}
|
|
592
|
+
if assignee_id:
|
|
593
|
+
body["assignee_id"] = assignee_id
|
|
594
|
+
if priority is not None:
|
|
595
|
+
body["priority"] = priority
|
|
596
|
+
if requirement_document:
|
|
597
|
+
body["requirement_document"] = requirement_document
|
|
598
|
+
return self._request(
|
|
599
|
+
"POST", f"/api/v1/users/projects/{project_id}/issues", json=body
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def update_project_issue(
|
|
603
|
+
self,
|
|
604
|
+
project_id: str,
|
|
605
|
+
issue_id: str,
|
|
606
|
+
title: str | None = None,
|
|
607
|
+
assignee_id: str | None = None,
|
|
608
|
+
priority: int | None = None,
|
|
609
|
+
status: str | None = None,
|
|
610
|
+
requirement_document: str | None = None,
|
|
611
|
+
design_document: str | None = None,
|
|
612
|
+
) -> Any:
|
|
613
|
+
"""更新项目 Issue。"""
|
|
614
|
+
body: dict[str, Any] = {}
|
|
615
|
+
if title is not None:
|
|
616
|
+
body["title"] = title
|
|
617
|
+
if assignee_id:
|
|
618
|
+
body["assignee_id"] = assignee_id
|
|
619
|
+
if priority is not None:
|
|
620
|
+
body["priority"] = priority
|
|
621
|
+
if status:
|
|
622
|
+
body["status"] = status
|
|
623
|
+
if requirement_document:
|
|
624
|
+
body["requirement_document"] = requirement_document
|
|
625
|
+
if design_document:
|
|
626
|
+
body["design_document"] = design_document
|
|
627
|
+
return self._request(
|
|
628
|
+
"PUT",
|
|
629
|
+
f"/api/v1/users/projects/{project_id}/issues/{issue_id}",
|
|
630
|
+
json=body,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def delete_project_issue(self, project_id: str, issue_id: str) -> Any:
|
|
634
|
+
"""删除项目 Issue。"""
|
|
635
|
+
return self._request(
|
|
636
|
+
"DELETE", f"/api/v1/users/projects/{project_id}/issues/{issue_id}"
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def list_issue_comments(
|
|
640
|
+
self,
|
|
641
|
+
project_id: str,
|
|
642
|
+
issue_id: str,
|
|
643
|
+
cursor: str | None = None,
|
|
644
|
+
limit: int | None = None,
|
|
645
|
+
) -> Any:
|
|
646
|
+
"""获取 Issue 评论列表。"""
|
|
647
|
+
params: dict[str, Any] = {}
|
|
648
|
+
if cursor:
|
|
649
|
+
params["cursor"] = cursor
|
|
650
|
+
if limit is not None:
|
|
651
|
+
params["limit"] = limit
|
|
652
|
+
return self._request(
|
|
653
|
+
"GET",
|
|
654
|
+
f"/api/v1/users/projects/{project_id}/issues/{issue_id}/comments",
|
|
655
|
+
params=params,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
def add_issue_comment(
|
|
659
|
+
self,
|
|
660
|
+
project_id: str,
|
|
661
|
+
issue_id: str,
|
|
662
|
+
comment: str,
|
|
663
|
+
parent_id: str | None = None,
|
|
664
|
+
) -> Any:
|
|
665
|
+
"""创建 Issue 评论。"""
|
|
666
|
+
body: dict[str, Any] = {"comment": comment}
|
|
667
|
+
if parent_id:
|
|
668
|
+
body["parent_id"] = parent_id
|
|
669
|
+
return self._request(
|
|
670
|
+
"POST",
|
|
671
|
+
f"/api/v1/users/projects/{project_id}/issues/{issue_id}/comments",
|
|
672
|
+
json=body,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# ---------- 插件 / 技能 ----------
|
|
676
|
+
|
|
677
|
+
def list_plugins(self) -> Any:
|
|
678
|
+
"""获取可用插件列表。"""
|
|
679
|
+
return self._request("GET", "/api/v1/plugins")
|
|
680
|
+
|
|
681
|
+
def list_skills(self) -> Any:
|
|
682
|
+
"""获取可用技能列表(配合 create_task 的 skill_ids)。"""
|
|
683
|
+
return self._request("GET", "/api/v1/skills")
|
|
684
|
+
|
|
685
|
+
# ---------- 钱包 / 签到 ----------
|
|
686
|
+
|
|
687
|
+
def get_wallet(self) -> Any:
|
|
688
|
+
"""查询钱包(积分余额)。"""
|
|
689
|
+
return self._request("GET", "/api/v1/users/wallet")
|
|
690
|
+
|
|
691
|
+
def get_wallet_transactions(self) -> Any:
|
|
692
|
+
"""查询钱包交易记录。"""
|
|
693
|
+
return self._request("GET", "/api/v1/users/wallet/transaction")
|
|
694
|
+
|
|
695
|
+
def checkin(self) -> Any:
|
|
696
|
+
"""每日签到(自动求解 PoW 验证码)。"""
|
|
697
|
+
token = self._solve_captcha()
|
|
698
|
+
return self._request(
|
|
699
|
+
"POST", "/api/v1/users/wallet/checkin", json={"captcha_token": token}
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
def get_subscription(self) -> Any:
|
|
703
|
+
"""查询会员订阅信息。"""
|
|
704
|
+
return self._request("GET", "/api/v1/users/subscription")
|
|
705
|
+
|
|
706
|
+
# ---------- Git 身份 / Git Bots ----------
|
|
707
|
+
|
|
708
|
+
def list_git_identities(self) -> Any:
|
|
709
|
+
"""获取 Git 身份列表。"""
|
|
710
|
+
return self._request("GET", "/api/v1/users/git-identities")
|
|
711
|
+
|
|
712
|
+
def list_git_branches(
|
|
713
|
+
self,
|
|
714
|
+
identity_id: str,
|
|
715
|
+
repo_full_name: str,
|
|
716
|
+
page: int = 1,
|
|
717
|
+
per_page: int = 50,
|
|
718
|
+
) -> Any:
|
|
719
|
+
"""获取指定仓库的分支列表。
|
|
720
|
+
|
|
721
|
+
repo_full_name 形如 "owner/repo",会自动做 URL 编码。
|
|
722
|
+
"""
|
|
723
|
+
escaped = quote(repo_full_name, safe="")
|
|
724
|
+
params: dict[str, Any] = {"page": page, "per_page": per_page}
|
|
725
|
+
return self._request(
|
|
726
|
+
"GET",
|
|
727
|
+
f"/api/v1/users/git-identities/{identity_id}/{escaped}/branches",
|
|
728
|
+
params=params,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
def add_git_identity(
|
|
732
|
+
self,
|
|
733
|
+
access_token: str,
|
|
734
|
+
base_url: str,
|
|
735
|
+
email: str,
|
|
736
|
+
platform: str,
|
|
737
|
+
username: str,
|
|
738
|
+
remark: str | None = None,
|
|
739
|
+
organization_id: str | None = None,
|
|
740
|
+
) -> Any:
|
|
741
|
+
"""添加 Git 身份。"""
|
|
742
|
+
body: dict[str, Any] = {
|
|
743
|
+
"access_token": access_token,
|
|
744
|
+
"base_url": base_url,
|
|
745
|
+
"email": email,
|
|
746
|
+
"platform": platform,
|
|
747
|
+
"username": username,
|
|
748
|
+
}
|
|
749
|
+
if remark:
|
|
750
|
+
body["remark"] = remark
|
|
751
|
+
if organization_id:
|
|
752
|
+
body["organization_id"] = organization_id
|
|
753
|
+
return self._request("POST", "/api/v1/users/git-identities", json=body)
|
|
754
|
+
|
|
755
|
+
def update_git_identity(
|
|
756
|
+
self, identity_id: str, **fields: Any
|
|
757
|
+
) -> Any:
|
|
758
|
+
"""更新 Git 身份(token/备注/组织等)。
|
|
759
|
+
|
|
760
|
+
字段以关键字参数传入,如 access_token、remark、organization_id。
|
|
761
|
+
"""
|
|
762
|
+
return self._request(
|
|
763
|
+
"PUT", f"/api/v1/users/git-identities/{identity_id}", json=fields
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
def delete_git_identity(self, identity_id: str) -> Any:
|
|
767
|
+
"""删除 Git 身份。"""
|
|
768
|
+
return self._request(
|
|
769
|
+
"DELETE", f"/api/v1/users/git-identities/{identity_id}"
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
def list_git_bots(self) -> Any:
|
|
773
|
+
"""获取 Git Bot 列表。"""
|
|
774
|
+
return self._request("GET", "/api/v1/users/git-bots")
|
|
775
|
+
|
|
776
|
+
def create_git_bot(
|
|
777
|
+
self,
|
|
778
|
+
host_id: str,
|
|
779
|
+
platform: str,
|
|
780
|
+
token: str,
|
|
781
|
+
name: str | None = None,
|
|
782
|
+
) -> Any:
|
|
783
|
+
"""创建 Git Bot。"""
|
|
784
|
+
body: dict[str, Any] = {
|
|
785
|
+
"host_id": host_id,
|
|
786
|
+
"platform": platform,
|
|
787
|
+
"token": token,
|
|
788
|
+
}
|
|
789
|
+
if name:
|
|
790
|
+
body["name"] = name
|
|
791
|
+
return self._request("POST", "/api/v1/users/git-bots", json=body)
|
|
792
|
+
|
|
793
|
+
def list_git_bot_tasks(
|
|
794
|
+
self, git_bot_id: str, page: int | None = None, size: int | None = None
|
|
795
|
+
) -> Any:
|
|
796
|
+
"""获取 Git Bot 任务列表。"""
|
|
797
|
+
params: dict[str, Any] = {"id": git_bot_id}
|
|
798
|
+
if page is not None:
|
|
799
|
+
params["page"] = page
|
|
800
|
+
if size is not None:
|
|
801
|
+
params["size"] = size
|
|
802
|
+
return self._request("GET", "/api/v1/users/git-bots/tasks", params=params)
|
|
803
|
+
|
|
804
|
+
# ---------- 文件管理 ----------
|
|
805
|
+
|
|
806
|
+
def list_folders(self, vm_id: str, path: str = "") -> Any:
|
|
807
|
+
"""列出虚拟机目录。"""
|
|
808
|
+
params: dict[str, Any] = {"id": vm_id, "path": path}
|
|
809
|
+
return self._request("GET", "/api/v1/users/folders", params=params)
|
|
810
|
+
|
|
811
|
+
def upload_file(
|
|
812
|
+
self,
|
|
813
|
+
vm_id: str,
|
|
814
|
+
path: str,
|
|
815
|
+
file_path: str | None = None,
|
|
816
|
+
file_bytes: bytes | None = None,
|
|
817
|
+
filename: str | None = None,
|
|
818
|
+
) -> Any:
|
|
819
|
+
"""上传文件到 VM 的绝对路径。
|
|
820
|
+
|
|
821
|
+
file_path 和 (file_bytes, filename) 二选一。
|
|
822
|
+
"""
|
|
823
|
+
if file_path is None and file_bytes is None:
|
|
824
|
+
raise ValueError("请提供 file_path 或 file_bytes。")
|
|
825
|
+
params: dict[str, Any] = {"id": vm_id, "path": path}
|
|
826
|
+
if file_path is not None:
|
|
827
|
+
import os
|
|
828
|
+
|
|
829
|
+
fname = filename or os.path.basename(file_path)
|
|
830
|
+
with open(file_path, "rb") as f:
|
|
831
|
+
files: dict[str, Any] = {"file": (fname, f)}
|
|
832
|
+
return self._request(
|
|
833
|
+
"POST",
|
|
834
|
+
"/api/v1/users/files/upload",
|
|
835
|
+
params=params,
|
|
836
|
+
files=files,
|
|
837
|
+
)
|
|
838
|
+
files = {"file": (filename or "file", file_bytes)}
|
|
839
|
+
return self._request(
|
|
840
|
+
"POST", "/api/v1/users/files/upload", params=params, files=files
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def download_file(self, vm_id: str, path: str, filename: str) -> bytes:
|
|
844
|
+
"""下载 VM 中的文件,返回字节内容。"""
|
|
845
|
+
params: dict[str, Any] = {"id": vm_id, "path": path, "filename": filename}
|
|
846
|
+
resp = self._client.get("/api/v1/users/files/download", params=params)
|
|
847
|
+
if resp.status_code != 200:
|
|
848
|
+
raise APIError(f"下载失败(HTTP {resp.status_code}):{resp.text[:200]}")
|
|
849
|
+
return resp.content
|
|
850
|
+
|
|
851
|
+
def move_file(self, vm_id: str, source: str, target: str) -> Any:
|
|
852
|
+
"""移动/重命名 VM 中的文件或目录。"""
|
|
853
|
+
body: dict[str, Any] = {"id": vm_id, "source": source, "target": target}
|
|
854
|
+
return self._request("PUT", "/api/v1/users/files/move", json=body)
|
|
855
|
+
|
|
856
|
+
def copy_file(self, vm_id: str, source: str, target: str) -> Any:
|
|
857
|
+
"""复制 VM 中的文件或目录。"""
|
|
858
|
+
body: dict[str, Any] = {"id": vm_id, "source": source, "target": target}
|
|
859
|
+
return self._request("POST", "/api/v1/users/files/copy", json=body)
|
|
860
|
+
|
|
861
|
+
def delete_file(self, vm_id: str, path: str) -> Any:
|
|
862
|
+
"""删除 VM 中的文件或目录。"""
|
|
863
|
+
params: dict[str, Any] = {"id": vm_id, "path": path}
|
|
864
|
+
return self._request("DELETE", "/api/v1/users/files", params=params)
|
|
865
|
+
|
|
866
|
+
# ---------- VM 管理 ----------
|
|
867
|
+
|
|
868
|
+
def get_vm(self, vm_id: str) -> Any:
|
|
869
|
+
"""获取虚拟机详情。"""
|
|
870
|
+
return self._request("GET", f"/api/v1/users/hosts/vms/{vm_id}")
|
|
871
|
+
|
|
872
|
+
def delete_vm(self, host_id: str, vm_id: str) -> Any:
|
|
873
|
+
"""删除虚拟机。"""
|
|
874
|
+
return self._request(
|
|
875
|
+
"DELETE", f"/api/v1/users/hosts/{host_id}/vms/{vm_id}"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def create_vm(self, host_id: str, **fields: Any) -> Any:
|
|
879
|
+
"""创建虚拟机/开发环境。
|
|
880
|
+
|
|
881
|
+
字段以关键字参数传入(如 image_id、resource、名称等),
|
|
882
|
+
与平台前端创建表单保持一致。
|
|
883
|
+
"""
|
|
884
|
+
return self._request(
|
|
885
|
+
"POST", f"/api/v1/users/hosts/{host_id}/vms", json=fields
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
def update_vm(self, host_id: str, vm_id: str, **fields: Any) -> Any:
|
|
889
|
+
"""更新虚拟机(如续期、调整配置)。"""
|
|
890
|
+
return self._request(
|
|
891
|
+
"PUT",
|
|
892
|
+
f"/api/v1/users/hosts/{host_id}/vms/{vm_id}",
|
|
893
|
+
json=fields,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
# ---------- 端口转发 ----------
|
|
897
|
+
|
|
898
|
+
def list_port_forwards(self, host_id: str, vm_id: str) -> Any:
|
|
899
|
+
"""列出虚拟机的端口转发规则。"""
|
|
900
|
+
return self._request(
|
|
901
|
+
"GET", f"/api/v1/users/hosts/{host_id}/vms/{vm_id}/ports"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
def add_port_forward(
|
|
905
|
+
self, host_id: str, vm_id: str, **fields: Any
|
|
906
|
+
) -> Any:
|
|
907
|
+
"""添加端口转发规则。
|
|
908
|
+
|
|
909
|
+
字段以关键字参数传入(如 port、target 等),与平台前端一致。
|
|
910
|
+
"""
|
|
911
|
+
return self._request(
|
|
912
|
+
"POST",
|
|
913
|
+
f"/api/v1/users/hosts/{host_id}/vms/{vm_id}/ports",
|
|
914
|
+
json=fields,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
def delete_port_forward(
|
|
918
|
+
self, host_id: str, vm_id: str, port: int | str
|
|
919
|
+
) -> Any:
|
|
920
|
+
"""删除端口转发规则。"""
|
|
921
|
+
return self._request(
|
|
922
|
+
"DELETE",
|
|
923
|
+
f"/api/v1/users/hosts/{host_id}/vms/{vm_id}/ports/{port}",
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
# ---------- 附件上传(任务/消息附件) ----------
|
|
927
|
+
|
|
928
|
+
def presign_upload(self, filename: str) -> dict[str, Any]:
|
|
929
|
+
"""获取附件预签名上传信息。
|
|
930
|
+
|
|
931
|
+
返回 data 通常包含 upload_url / access_url 等字段。
|
|
932
|
+
"""
|
|
933
|
+
return self._request(
|
|
934
|
+
"POST",
|
|
935
|
+
"/api/v1/uploader/presign",
|
|
936
|
+
json={"filename": filename},
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
def upload_attachment(
|
|
940
|
+
self,
|
|
941
|
+
filename: str,
|
|
942
|
+
file_path: str | None = None,
|
|
943
|
+
file_bytes: bytes | None = None,
|
|
944
|
+
) -> dict[str, Any]:
|
|
945
|
+
"""上传附件并返回 access_url。
|
|
946
|
+
|
|
947
|
+
流程:presign 获取 upload_url/access_url -> 用 PUT 上传文件内容。
|
|
948
|
+
上传成功后可用返回的 access_url 作为任务/消息的附件 url。
|
|
949
|
+
"""
|
|
950
|
+
if file_path is None and file_bytes is None:
|
|
951
|
+
raise ValueError("请提供 file_path 或 file_bytes。")
|
|
952
|
+
presigned = self.presign_upload(filename)
|
|
953
|
+
upload_url = presigned.get("upload_url")
|
|
954
|
+
if not upload_url:
|
|
955
|
+
raise APIError(f"presign 响应缺少 upload_url:{presigned}")
|
|
956
|
+
if file_path is not None:
|
|
957
|
+
with open(file_path, "rb") as f:
|
|
958
|
+
content = f.read()
|
|
959
|
+
else:
|
|
960
|
+
content = file_bytes
|
|
961
|
+
resp = self._client.put(upload_url, content=content)
|
|
962
|
+
if resp.status_code not in (200, 201, 204):
|
|
963
|
+
raise APIError(
|
|
964
|
+
f"附件上传失败(HTTP {resp.status_code}):{resp.text[:200]}"
|
|
965
|
+
)
|
|
966
|
+
return presigned
|
|
967
|
+
|
|
968
|
+
def upload_object(
|
|
969
|
+
self,
|
|
970
|
+
filename: str,
|
|
971
|
+
file_bytes: bytes,
|
|
972
|
+
usage: str = "attachment",
|
|
973
|
+
) -> Any:
|
|
974
|
+
"""直接 multipart 上传文件对象(POST /api/v1/uploader)。
|
|
975
|
+
|
|
976
|
+
与 presign 路径(upload_attachment)互补;usage 按平台约定
|
|
977
|
+
传入(如 attachment / avatar 等)。
|
|
978
|
+
"""
|
|
979
|
+
return self._request(
|
|
980
|
+
"POST",
|
|
981
|
+
"/api/v1/uploader",
|
|
982
|
+
files={"file": (filename, file_bytes)},
|
|
983
|
+
params={"usage": usage},
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
# ---------- 扩展 / 管理功能(覆盖完整 API,按需使用) ----------
|
|
987
|
+
|
|
988
|
+
def create_image(self, image_name: str, is_default: bool | None = None, remark: str | None = None) -> Any:
|
|
989
|
+
"""创建镜像。"""
|
|
990
|
+
body: dict[str, Any] = {"image_name": image_name}
|
|
991
|
+
if is_default is not None:
|
|
992
|
+
body["is_default"] = is_default
|
|
993
|
+
if remark:
|
|
994
|
+
body["remark"] = remark
|
|
995
|
+
return self._request("POST", "/api/v1/users/images", json=body)
|
|
996
|
+
|
|
997
|
+
def update_image(self, image_id: str, image_name: str | None = None, is_default: bool | None = None, remark: str | None = None) -> Any:
|
|
998
|
+
"""更新镜像。"""
|
|
999
|
+
body: dict[str, Any] = {}
|
|
1000
|
+
if image_name is not None:
|
|
1001
|
+
body["image_name"] = image_name
|
|
1002
|
+
if is_default is not None:
|
|
1003
|
+
body["is_default"] = is_default
|
|
1004
|
+
if remark:
|
|
1005
|
+
body["remark"] = remark
|
|
1006
|
+
return self._request("PUT", f"/api/v1/users/images/{image_id}", json=body)
|
|
1007
|
+
|
|
1008
|
+
def delete_image(self, image_id: str) -> Any:
|
|
1009
|
+
"""删除镜像。"""
|
|
1010
|
+
return self._request("DELETE", f"/api/v1/users/images/{image_id}")
|
|
1011
|
+
|
|
1012
|
+
def create_subscription(self) -> Any:
|
|
1013
|
+
"""开通会员订阅。"""
|
|
1014
|
+
return self._request("POST", "/api/v1/users/subscription", json={})
|
|
1015
|
+
|
|
1016
|
+
def set_auto_renew(self, enabled: bool) -> Any:
|
|
1017
|
+
"""设置订阅自动续费。"""
|
|
1018
|
+
return self._request("PUT", "/api/v1/users/subscription/auto-renew", json={"enabled": enabled})
|
|
1019
|
+
|
|
1020
|
+
def set_credit_consumption(self, enabled: bool) -> Any:
|
|
1021
|
+
"""设置订阅积分消费。"""
|
|
1022
|
+
return self._request("PUT", "/api/v1/users/subscription/credit-consumption", json={"enabled": enabled})
|
|
1023
|
+
|
|
1024
|
+
def list_invitations(self) -> Any:
|
|
1025
|
+
"""获取邀请列表。"""
|
|
1026
|
+
return self._request("GET", "/api/v1/users/invitations")
|
|
1027
|
+
|
|
1028
|
+
def list_mcp_upstreams(self) -> Any:
|
|
1029
|
+
"""获取 MCP 上游列表。"""
|
|
1030
|
+
return self._request("GET", "/api/v1/users/mcp/upstreams")
|
|
1031
|
+
|
|
1032
|
+
def create_mcp_upstream(self, name: str, url: str, headers: dict[str, Any] | None = None) -> Any:
|
|
1033
|
+
"""创建 MCP 上游。"""
|
|
1034
|
+
body: dict[str, Any] = {"name": name, "url": url}
|
|
1035
|
+
if headers:
|
|
1036
|
+
body["headers"] = headers
|
|
1037
|
+
return self._request("POST", "/api/v1/users/mcp/upstreams", json=body)
|
|
1038
|
+
|
|
1039
|
+
def update_mcp_upstream(self, upstream_id: str, **fields: Any) -> Any:
|
|
1040
|
+
"""更新 MCP 上游。"""
|
|
1041
|
+
return self._request("PUT", f"/api/v1/users/mcp/upstreams/{upstream_id}", json=fields)
|
|
1042
|
+
|
|
1043
|
+
def delete_mcp_upstream(self, upstream_id: str) -> Any:
|
|
1044
|
+
"""删除 MCP 上游。"""
|
|
1045
|
+
return self._request("DELETE", f"/api/v1/users/mcp/upstreams/{upstream_id}")
|
|
1046
|
+
|
|
1047
|
+
def sync_mcp_upstream(self, upstream_id: str) -> Any:
|
|
1048
|
+
"""同步 MCP 上游。"""
|
|
1049
|
+
return self._request("POST", f"/api/v1/users/mcp/upstreams/{upstream_id}/sync", json={})
|
|
1050
|
+
|
|
1051
|
+
def set_mcp_tool(self, tool_id: str, enabled: bool) -> Any:
|
|
1052
|
+
"""启用/关闭 MCP 工具。"""
|
|
1053
|
+
return self._request("PUT", f"/api/v1/users/mcp/tools/{tool_id}", json={"enabled": enabled})
|
|
1054
|
+
|
|
1055
|
+
def list_notify_channels(self) -> Any:
|
|
1056
|
+
"""获取通知渠道列表。"""
|
|
1057
|
+
return self._request("GET", "/api/v1/users/notify/channels")
|
|
1058
|
+
|
|
1059
|
+
def create_notify_channel(self, name: str, kind: str, webhook_url: str, event_types: list[str], headers: dict[str, Any] | None = None) -> Any:
|
|
1060
|
+
"""创建通知渠道。"""
|
|
1061
|
+
body: dict[str, Any] = {
|
|
1062
|
+
"name": name, "kind": kind, "webhook_url": webhook_url, "event_types": event_types,
|
|
1063
|
+
}
|
|
1064
|
+
if headers:
|
|
1065
|
+
body["headers"] = headers
|
|
1066
|
+
return self._request("POST", "/api/v1/users/notify/channels", json=body)
|
|
1067
|
+
|
|
1068
|
+
def update_notify_channel(self, channel_id: str, **fields: Any) -> Any:
|
|
1069
|
+
"""更新通知渠道。"""
|
|
1070
|
+
return self._request("PUT", f"/api/v1/users/notify/channels/{channel_id}", json=fields)
|
|
1071
|
+
|
|
1072
|
+
def delete_notify_channel(self, channel_id: str) -> Any:
|
|
1073
|
+
"""删除通知渠道。"""
|
|
1074
|
+
return self._request("DELETE", f"/api/v1/users/notify/channels/{channel_id}")
|
|
1075
|
+
|
|
1076
|
+
def test_notify_channel(self, channel_id: str) -> Any:
|
|
1077
|
+
"""测试通知渠道。"""
|
|
1078
|
+
return self._request("POST", f"/api/v1/users/notify/channels/{channel_id}/test", json={})
|
|
1079
|
+
|
|
1080
|
+
def list_notify_event_types(self) -> Any:
|
|
1081
|
+
"""获取通知事件类型。"""
|
|
1082
|
+
return self._request("GET", "/api/v1/users/notify/event-types")
|
|
1083
|
+
|
|
1084
|
+
def update_user_profile(self, name: str | None = None, avatar_url: str | None = None) -> Any:
|
|
1085
|
+
"""更新用户昵称/头像。"""
|
|
1086
|
+
body: dict[str, Any] = {}
|
|
1087
|
+
if name is not None:
|
|
1088
|
+
body["name"] = name
|
|
1089
|
+
if avatar_url is not None:
|
|
1090
|
+
body["avatar_url"] = avatar_url
|
|
1091
|
+
return self._request("PUT", "/api/v1/users", json=body)
|
|
1092
|
+
|
|
1093
|
+
def delete_account(self) -> Any:
|
|
1094
|
+
"""注销账号。"""
|
|
1095
|
+
return self._request("DELETE", "/api/v1/users/account")
|
|
1096
|
+
|
|
1097
|
+
def change_password(self, new_password: str, current_password: str | None = None) -> Any:
|
|
1098
|
+
"""修改密码。"""
|
|
1099
|
+
body: dict[str, Any] = {"new_password": new_password}
|
|
1100
|
+
if current_password:
|
|
1101
|
+
body["current_password"] = current_password
|
|
1102
|
+
return self._request("PUT", "/api/v1/users/passwords/change", json=body)
|
|
1103
|
+
|
|
1104
|
+
def request_password_reset(self, email: str) -> Any:
|
|
1105
|
+
"""发送密码重置邮件。"""
|
|
1106
|
+
return self._request("PUT", "/api/v1/users/passwords/reset-request", json={"email": email})
|
|
1107
|
+
|
|
1108
|
+
def reset_password(self, token: str, new_password: str) -> Any:
|
|
1109
|
+
"""重置密码。"""
|
|
1110
|
+
return self._request("PUT", "/api/v1/users/passwords/reset", json={"token": token, "new_password": new_password})
|
|
1111
|
+
|
|
1112
|
+
def get_reset_password_account(self, token: str) -> Any:
|
|
1113
|
+
"""用重置链接中的 token 查询账户信息(网页重置用)。"""
|
|
1114
|
+
return self._request(
|
|
1115
|
+
"GET", f"/api/v1/users/passwords/accounts/{token}"
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
def update_git_bot(self, **fields: Any) -> Any:
|
|
1119
|
+
"""更新 Git Bot。"""
|
|
1120
|
+
return self._request("PUT", "/api/v1/users/git-bots", json=fields)
|
|
1121
|
+
|
|
1122
|
+
def delete_git_bot(self, git_bot_id: str) -> Any:
|
|
1123
|
+
"""删除 Git Bot。"""
|
|
1124
|
+
return self._request("DELETE", f"/api/v1/users/git-bots/{git_bot_id}")
|
|
1125
|
+
|
|
1126
|
+
def share_git_bot(self, git_bot_id: str, user_ids: list[str]) -> Any:
|
|
1127
|
+
"""分享 Git Bot。"""
|
|
1128
|
+
return self._request("POST", "/api/v1/users/git-bots/share", json={"id": git_bot_id, "user_ids": user_ids})
|
|
1129
|
+
|
|
1130
|
+
def list_members(self) -> Any:
|
|
1131
|
+
"""获取团队成员列表。"""
|
|
1132
|
+
return self._request("GET", "/api/v1/users/members")
|
|
1133
|
+
|
|
1134
|
+
def get_public_stats(self) -> Any:
|
|
1135
|
+
"""获取公开统计。"""
|
|
1136
|
+
return self._request("GET", "/api/v1/public/stats")
|
|
1137
|
+
|
|
1138
|
+
def bind_email_request(self, email: str) -> Any:
|
|
1139
|
+
"""请求绑定邮箱。"""
|
|
1140
|
+
return self._request("PUT", "/api/v1/users/email/bind-request", json={"email": email})
|
|
1141
|
+
|
|
1142
|
+
def bind_wechat_qrcode(self) -> Any:
|
|
1143
|
+
"""创建微信公众号绑定二维码。"""
|
|
1144
|
+
return self._request("POST", "/api/v1/users/wechat-mp/bind-qrcode", json={})
|
|
1145
|
+
|
|
1146
|
+
def unbind_wechat(self) -> Any:
|
|
1147
|
+
"""解绑微信公众号。"""
|
|
1148
|
+
return self._request("DELETE", "/api/v1/users/wechat-mp/bind")
|
|
1149
|
+
|
|
1150
|
+
def list_playground_posts(self) -> Any:
|
|
1151
|
+
"""获取社区帖子列表。"""
|
|
1152
|
+
return self._request("GET", "/api/v1/playground-posts")
|
|
1153
|
+
|
|
1154
|
+
def create_playground_post(self, content: str) -> Any:
|
|
1155
|
+
"""发布普通社区帖子。"""
|
|
1156
|
+
return self._request("POST", "/api/v1/users/playground-normal-posts", json={"content": content})
|
|
1157
|
+
|
|
1158
|
+
def create_playground_task_post(self, task_id: str, content: str) -> Any:
|
|
1159
|
+
"""发布任务帖。"""
|
|
1160
|
+
return self._request("POST", f"/api/v1/users/playground-task-posts/{task_id}", json={"content": content})
|
|
1161
|
+
|
|
1162
|
+
def get_license_status(self) -> Any:
|
|
1163
|
+
"""获取 License 状态。"""
|
|
1164
|
+
return self._request("GET", "/api/v1/license/status")
|
|
1165
|
+
|
|
1166
|
+
def get_license_machine_code(self) -> Any:
|
|
1167
|
+
"""获取机器码。"""
|
|
1168
|
+
return self._request("GET", "/api/v1/license/machine-code")
|
|
1169
|
+
|
|
1170
|
+
def import_license(self, license_key: str) -> Any:
|
|
1171
|
+
"""导入 License。"""
|
|
1172
|
+
return self._request("POST", "/api/v1/license/import", json={"license": license_key})
|
|
1173
|
+
|
|
1174
|
+
def get_cnb_authorize_url(self) -> Any:
|
|
1175
|
+
"""获取 CNB 授权 URL。"""
|
|
1176
|
+
return self._request("GET", "/api/v1/cnb/authorize_url")
|
|
1177
|
+
|
|
1178
|
+
def get_gitea_authorize_url(self) -> Any:
|
|
1179
|
+
"""获取 Gitea 授权 URL。"""
|
|
1180
|
+
return self._request("GET", "/api/v1/gitea/authorize_url")
|
|
1181
|
+
|
|
1182
|
+
def get_gitea_sites(self) -> Any:
|
|
1183
|
+
"""获取 Gitea 站点列表。"""
|
|
1184
|
+
return self._request("GET", "/api/v1/gitea/sites")
|
|
1185
|
+
|
|
1186
|
+
def get_gitee_authorize_url(self) -> Any:
|
|
1187
|
+
"""获取 Gitee 授权 URL。"""
|
|
1188
|
+
return self._request("GET", "/api/v1/gitee/authorize_url")
|
|
1189
|
+
|
|
1190
|
+
def get_gitlab_authorize_url(self) -> Any:
|
|
1191
|
+
"""获取 GitLab 授权 URL。"""
|
|
1192
|
+
return self._request("GET", "/api/v1/gitlab/authorize_url")
|
|
1193
|
+
|
|
1194
|
+
def get_gitlab_sites(self) -> Any:
|
|
1195
|
+
"""获取 GitLab 站点列表。"""
|
|
1196
|
+
return self._request("GET", "/api/v1/gitlab/sites")
|
|
1197
|
+
|
|
1198
|
+
def build_oauth_login_url(self, provider: str, redirect_url: str | None = None) -> str:
|
|
1199
|
+
"""构造 OAuth 登录跳转 URL(供浏览器使用)。"""
|
|
1200
|
+
base = f"{self.base_url}/api/v1/users/oauth/{provider}/login"
|
|
1201
|
+
if redirect_url:
|
|
1202
|
+
from urllib.parse import urlencode
|
|
1203
|
+
return f"{base}?{urlencode({'redirect_url': redirect_url})}"
|
|
1204
|
+
return base
|
|
1205
|
+
|
|
1206
|
+
def build_oidc_login_url(self, team_id: str | None = None) -> str:
|
|
1207
|
+
"""构造企业 OIDC 登录跳转 URL(供浏览器使用)。"""
|
|
1208
|
+
base = f"{self.base_url}/api/v1/users/oidc/login"
|
|
1209
|
+
if team_id:
|
|
1210
|
+
from urllib.parse import urlencode
|
|
1211
|
+
return f"{base}?{urlencode({'team_id': team_id})}"
|
|
1212
|
+
return base
|
|
1213
|
+
|
|
1214
|
+
def speech_to_text(self, **fields: Any) -> Any:
|
|
1215
|
+
"""语音转文字(提交音频)。"""
|
|
1216
|
+
return self._request("POST", "/api/v1/users/tasks/speech-to-text", json=fields)
|
|
1217
|
+
|
|
1218
|
+
# ---------- 团队管理(管理员控制面) ----------
|
|
1219
|
+
|
|
1220
|
+
def team_admin_initialize(self) -> Any:
|
|
1221
|
+
"""初始化团队。"""
|
|
1222
|
+
return self._request("POST", "/api/v1/teams/admin", json={})
|
|
1223
|
+
|
|
1224
|
+
def team_list_groups(self) -> Any:
|
|
1225
|
+
"""团队分组列表。"""
|
|
1226
|
+
return self._request("GET", "/api/v1/teams/groups")
|
|
1227
|
+
|
|
1228
|
+
def team_create_group(self, name: str) -> Any:
|
|
1229
|
+
"""创建团队分组。"""
|
|
1230
|
+
return self._request("POST", "/api/v1/teams/groups", json={"name": name})
|
|
1231
|
+
|
|
1232
|
+
def team_delete_group(self, group_id: str) -> Any:
|
|
1233
|
+
"""删除团队分组。"""
|
|
1234
|
+
return self._request("DELETE", f"/api/v1/teams/groups/{group_id}")
|
|
1235
|
+
|
|
1236
|
+
def team_list_group_users(self, group_id: str) -> Any:
|
|
1237
|
+
"""获取分组内用户。"""
|
|
1238
|
+
return self._request("GET", f"/api/v1/teams/groups/{group_id}/users")
|
|
1239
|
+
|
|
1240
|
+
def team_set_group_users(self, group_id: str, user_ids: list[str]) -> Any:
|
|
1241
|
+
"""调整分组内用户。"""
|
|
1242
|
+
return self._request("PUT", f"/api/v1/teams/groups/{group_id}/users", json={"user_ids": user_ids})
|
|
1243
|
+
|
|
1244
|
+
def team_list_users(self) -> Any:
|
|
1245
|
+
"""团队成员列表。"""
|
|
1246
|
+
return self._request("GET", "/api/v1/teams/users")
|
|
1247
|
+
|
|
1248
|
+
def team_add_user(self, **fields: Any) -> Any:
|
|
1249
|
+
"""添加团队成员。"""
|
|
1250
|
+
return self._request("POST", "/api/v1/teams/users", json=fields)
|
|
1251
|
+
|
|
1252
|
+
def team_delete_user(self, user_id: str) -> Any:
|
|
1253
|
+
"""删除团队成员。"""
|
|
1254
|
+
return self._request("DELETE", f"/api/v1/teams/users/{user_id}")
|
|
1255
|
+
|
|
1256
|
+
def team_list_hosts(self) -> Any:
|
|
1257
|
+
"""团队宿主列表。"""
|
|
1258
|
+
return self._request("GET", "/api/v1/teams/hosts")
|
|
1259
|
+
|
|
1260
|
+
def team_list_images(self) -> Any:
|
|
1261
|
+
"""团队镜像列表。"""
|
|
1262
|
+
return self._request("GET", "/api/v1/teams/images")
|
|
1263
|
+
|
|
1264
|
+
def team_list_models(self) -> Any:
|
|
1265
|
+
"""团队模型列表。"""
|
|
1266
|
+
return self._request("GET", "/api/v1/teams/models")
|
|
1267
|
+
|
|
1268
|
+
def team_list_skills(self) -> Any:
|
|
1269
|
+
"""团队技能列表。"""
|
|
1270
|
+
return self._request("GET", "/api/v1/teams/skills")
|
|
1271
|
+
|
|
1272
|
+
def team_list_projects(self) -> Any:
|
|
1273
|
+
"""团队项目列表。"""
|
|
1274
|
+
return self._request("GET", "/api/v1/teams/projects")
|
|
1275
|
+
|
|
1276
|
+
def team_list_tasks(self) -> Any:
|
|
1277
|
+
"""团队任务列表。"""
|
|
1278
|
+
return self._request("GET", "/api/v1/teams/tasks")
|
|
1279
|
+
|
|
1280
|
+
def team_get_oidc(self) -> Any:
|
|
1281
|
+
"""获取团队 OIDC 配置。"""
|
|
1282
|
+
return self._request("GET", "/api/v1/teams/oidc")
|
|
1283
|
+
|
|
1284
|
+
def team_save_oidc(self, **fields: Any) -> Any:
|
|
1285
|
+
"""保存团队 OIDC 配置。"""
|
|
1286
|
+
return self._request("PUT", "/api/v1/teams/oidc", json=fields)
|
|
1287
|
+
|
|
1288
|
+
def team_test_oidc(self, **fields: Any) -> Any:
|
|
1289
|
+
"""测试团队 OIDC 配置。"""
|
|
1290
|
+
return self._request("POST", "/api/v1/teams/oidc/test", json=fields)
|
|
1291
|
+
|
|
1292
|
+
def team_list_mcp_upstreams(self) -> Any:
|
|
1293
|
+
"""团队 MCP 上游列表。"""
|
|
1294
|
+
return self._request("GET", "/api/v1/teams/mcp/upstreams")
|
|
1295
|
+
|
|
1296
|
+
def team_list_notify_channels(self) -> Any:
|
|
1297
|
+
"""团队通知渠道列表。"""
|
|
1298
|
+
return self._request("GET", "/api/v1/teams/notify/channels")
|
|
1299
|
+
|
|
1300
|
+
def team_list_oauth_sites(self) -> Any:
|
|
1301
|
+
"""团队 OAuth 站点列表。"""
|
|
1302
|
+
return self._request("GET", "/api/v1/teams/oauth-sites")
|
|
1303
|
+
|
|
1304
|
+
def team_list_extension_packages(self) -> Any:
|
|
1305
|
+
"""团队扩展包列表。"""
|
|
1306
|
+
return self._request("GET", "/api/v1/teams/extension-packages")
|
|
1307
|
+
|
|
1308
|
+
def team_get_vm_idle_policy(self) -> Any:
|
|
1309
|
+
"""获取 VM 空闲策略。"""
|
|
1310
|
+
return self._request("GET", "/api/v1/teams/task-vm-idle-policy")
|
|
1311
|
+
|
|
1312
|
+
def team_set_vm_idle_policy(self, **fields: Any) -> Any:
|
|
1313
|
+
"""设置 VM 空闲策略。"""
|
|
1314
|
+
return self._request("PUT", "/api/v1/teams/task-vm-idle-policy", json=fields)
|
|
1315
|
+
|
|
1316
|
+
def team_list_audits(self) -> Any:
|
|
1317
|
+
"""获取团队审计日志。"""
|
|
1318
|
+
return self._request("GET", "/api/v1/teams/audits")
|
|
1319
|
+
|
|
1320
|
+
def team_get_dashboard(self) -> Any:
|
|
1321
|
+
"""获取团队 Dashboard。"""
|
|
1322
|
+
return self._request("GET", "/api/v1/teams/dashboard")
|
|
1323
|
+
|
|
1324
|
+
# ---------- 团队用户(管理写操作) ----------
|
|
1325
|
+
|
|
1326
|
+
def team_get_user_status(self) -> Any:
|
|
1327
|
+
"""查询当前团队用户状态(团队会话)。"""
|
|
1328
|
+
return self._request("GET", "/api/v1/teams/users/status")
|
|
1329
|
+
|
|
1330
|
+
def team_change_password(self, **fields: Any) -> Any:
|
|
1331
|
+
"""团队用户修改密码。"""
|
|
1332
|
+
return self._request(
|
|
1333
|
+
"PUT", "/api/v1/teams/users/passwords/change", json=fields
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
def team_add_user_with_password(self, **fields: Any) -> Any:
|
|
1337
|
+
"""创建团队成员并返回初始密码(仅响应返回一次)。"""
|
|
1338
|
+
return self._request(
|
|
1339
|
+
"POST", "/api/v1/teams/users/with-password", json=fields
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
def team_update_user(self, user_id: str, **fields: Any) -> Any:
|
|
1343
|
+
"""更新团队成员信息。"""
|
|
1344
|
+
return self._request(
|
|
1345
|
+
"PUT", f"/api/v1/teams/users/{user_id}", json=fields
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
def team_reset_user_password(self, user_id: str) -> Any:
|
|
1349
|
+
"""管理员重置成员密码(仅响应返回一次)。"""
|
|
1350
|
+
return self._request(
|
|
1351
|
+
"PUT", f"/api/v1/teams/users/{user_id}/passwords/reset"
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
# ---------- 团队分组(管理写操作) ----------
|
|
1355
|
+
|
|
1356
|
+
def team_update_group(self, group_id: str, name: str) -> Any:
|
|
1357
|
+
"""更新分组名称。"""
|
|
1358
|
+
return self._request(
|
|
1359
|
+
"PUT", f"/api/v1/teams/groups/{group_id}", json={"name": name}
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
# ---------- 团队宿主(管理写操作) ----------
|
|
1363
|
+
|
|
1364
|
+
def team_get_host_install_command(self) -> Any:
|
|
1365
|
+
"""获取团队宿主机安装命令。"""
|
|
1366
|
+
return self._request("GET", "/api/v1/teams/hosts/install-command")
|
|
1367
|
+
|
|
1368
|
+
def team_update_host(self, host_id: str, **fields: Any) -> Any:
|
|
1369
|
+
"""更新团队宿主机。"""
|
|
1370
|
+
return self._request(
|
|
1371
|
+
"PUT", f"/api/v1/teams/hosts/{host_id}", json=fields
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
def team_delete_host(self, host_id: str) -> Any:
|
|
1375
|
+
"""删除团队宿主机。"""
|
|
1376
|
+
return self._request("DELETE", f"/api/v1/teams/hosts/{host_id}")
|
|
1377
|
+
|
|
1378
|
+
# ---------- 团队镜像(管理写操作) ----------
|
|
1379
|
+
|
|
1380
|
+
def team_create_image(self, **fields: Any) -> Any:
|
|
1381
|
+
"""创建团队镜像。"""
|
|
1382
|
+
return self._request("POST", "/api/v1/teams/images", json=fields)
|
|
1383
|
+
|
|
1384
|
+
def team_update_image(self, image_id: str, **fields: Any) -> Any:
|
|
1385
|
+
"""更新团队镜像。"""
|
|
1386
|
+
return self._request(
|
|
1387
|
+
"PUT", f"/api/v1/teams/images/{image_id}", json=fields
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
def team_delete_image(self, image_id: str) -> Any:
|
|
1391
|
+
"""删除团队镜像。"""
|
|
1392
|
+
return self._request("DELETE", f"/api/v1/teams/images/{image_id}")
|
|
1393
|
+
|
|
1394
|
+
# ---------- 团队模型(管理写操作) ----------
|
|
1395
|
+
|
|
1396
|
+
def team_create_model(self, **fields: Any) -> Any:
|
|
1397
|
+
"""创建团队模型配置。"""
|
|
1398
|
+
return self._request("POST", "/api/v1/teams/models", json=fields)
|
|
1399
|
+
|
|
1400
|
+
def team_update_model(self, model_id: str, **fields: Any) -> Any:
|
|
1401
|
+
"""更新团队模型。"""
|
|
1402
|
+
return self._request(
|
|
1403
|
+
"PUT", f"/api/v1/teams/models/{model_id}", json=fields
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
def team_delete_model(self, model_id: str) -> Any:
|
|
1407
|
+
"""删除团队模型。"""
|
|
1408
|
+
return self._request("DELETE", f"/api/v1/teams/models/{model_id}")
|
|
1409
|
+
|
|
1410
|
+
def team_model_health_check(self, model_ids: list[str]) -> Any:
|
|
1411
|
+
"""团队模型批量健康检查。"""
|
|
1412
|
+
return self._request(
|
|
1413
|
+
"POST",
|
|
1414
|
+
"/api/v1/teams/models/health-check",
|
|
1415
|
+
json={"model_ids": model_ids},
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
def team_get_model_health_check(self, model_id: str) -> Any:
|
|
1419
|
+
"""单个团队模型健康检查。"""
|
|
1420
|
+
return self._request(
|
|
1421
|
+
"GET", f"/api/v1/teams/models/{model_id}/health-check"
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
# ---------- 团队技能(管理写操作) ----------
|
|
1425
|
+
|
|
1426
|
+
def team_create_skill(self, **fields: Any) -> Any:
|
|
1427
|
+
"""创建团队技能。"""
|
|
1428
|
+
return self._request("POST", "/api/v1/teams/skills", json=fields)
|
|
1429
|
+
|
|
1430
|
+
def team_create_skill_package(self, **fields: Any) -> Any:
|
|
1431
|
+
"""上传/创建团队技能包。"""
|
|
1432
|
+
return self._request(
|
|
1433
|
+
"POST", "/api/v1/teams/skills/package", json=fields
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
def team_update_skill(self, skill_id: str, **fields: Any) -> Any:
|
|
1437
|
+
"""更新团队技能。"""
|
|
1438
|
+
return self._request(
|
|
1439
|
+
"PUT", f"/api/v1/teams/skills/{skill_id}", json=fields
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
def team_delete_skill(self, skill_id: str) -> Any:
|
|
1443
|
+
"""删除团队技能。"""
|
|
1444
|
+
return self._request("DELETE", f"/api/v1/teams/skills/{skill_id}")
|
|
1445
|
+
|
|
1446
|
+
# ---------- 团队 MCP(管理写操作) ----------
|
|
1447
|
+
|
|
1448
|
+
def team_create_mcp_upstream(self, **fields: Any) -> Any:
|
|
1449
|
+
"""创建团队 MCP 上游。"""
|
|
1450
|
+
return self._request(
|
|
1451
|
+
"POST", "/api/v1/teams/mcp/upstreams", json=fields
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
def team_update_mcp_upstream(
|
|
1455
|
+
self, upstream_id: str, **fields: Any
|
|
1456
|
+
) -> Any:
|
|
1457
|
+
"""更新团队 MCP 上游。"""
|
|
1458
|
+
return self._request(
|
|
1459
|
+
"PUT", f"/api/v1/teams/mcp/upstreams/{upstream_id}", json=fields
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
def team_delete_mcp_upstream(self, upstream_id: str) -> Any:
|
|
1463
|
+
"""删除团队 MCP 上游。"""
|
|
1464
|
+
return self._request(
|
|
1465
|
+
"DELETE", f"/api/v1/teams/mcp/upstreams/{upstream_id}"
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
def team_sync_mcp_upstream(self, upstream_id: str) -> Any:
|
|
1469
|
+
"""同步团队 MCP 上游。"""
|
|
1470
|
+
return self._request(
|
|
1471
|
+
"POST", f"/api/v1/teams/mcp/upstreams/{upstream_id}/sync"
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
# ---------- 团队通知(管理写操作) ----------
|
|
1475
|
+
|
|
1476
|
+
def team_create_notify_channel(self, **fields: Any) -> Any:
|
|
1477
|
+
"""创建团队通知渠道。"""
|
|
1478
|
+
return self._request(
|
|
1479
|
+
"POST", "/api/v1/teams/notify/channels", json=fields
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
def team_update_notify_channel(
|
|
1483
|
+
self, channel_id: str, **fields: Any
|
|
1484
|
+
) -> Any:
|
|
1485
|
+
"""更新团队通知渠道。"""
|
|
1486
|
+
return self._request(
|
|
1487
|
+
"PUT", f"/api/v1/teams/notify/channels/{channel_id}", json=fields
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
def team_delete_notify_channel(self, channel_id: str) -> Any:
|
|
1491
|
+
"""删除团队通知渠道。"""
|
|
1492
|
+
return self._request(
|
|
1493
|
+
"DELETE", f"/api/v1/teams/notify/channels/{channel_id}"
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
def team_test_notify_channel(self, channel_id: str) -> Any:
|
|
1497
|
+
"""测试团队通知渠道。"""
|
|
1498
|
+
return self._request(
|
|
1499
|
+
"POST", f"/api/v1/teams/notify/channels/{channel_id}/test"
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
def team_list_notify_event_types(self) -> Any:
|
|
1503
|
+
"""团队通知事件类型列表。"""
|
|
1504
|
+
return self._request("GET", "/api/v1/teams/notify/event-types")
|
|
1505
|
+
|
|
1506
|
+
# ---------- 团队 OAuth 站点(管理写操作) ----------
|
|
1507
|
+
|
|
1508
|
+
def team_create_oauth_site(self, **fields: Any) -> Any:
|
|
1509
|
+
"""创建团队 OAuth 站点配置。"""
|
|
1510
|
+
return self._request("POST", "/api/v1/teams/oauth-sites", json=fields)
|
|
1511
|
+
|
|
1512
|
+
def team_update_oauth_site(self, site_id: str, **fields: Any) -> Any:
|
|
1513
|
+
"""更新团队 OAuth 站点。"""
|
|
1514
|
+
return self._request(
|
|
1515
|
+
"PUT", f"/api/v1/teams/oauth-sites/{site_id}", json=fields
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
def team_delete_oauth_site(self, site_id: str) -> Any:
|
|
1519
|
+
"""删除团队 OAuth 站点。"""
|
|
1520
|
+
return self._request("DELETE", f"/api/v1/teams/oauth-sites/{site_id}")
|
|
1521
|
+
|
|
1522
|
+
# ---------- 团队扩展包(管理写操作) ----------
|
|
1523
|
+
|
|
1524
|
+
def team_create_extension_package(self, **fields: Any) -> Any:
|
|
1525
|
+
"""创建团队扩展包。"""
|
|
1526
|
+
return self._request(
|
|
1527
|
+
"POST", "/api/v1/teams/extension-packages", json=fields
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
# ---------- 任务 ----------
|
|
1531
|
+
|
|
1532
|
+
def create_task(
|
|
1533
|
+
self,
|
|
1534
|
+
content: str,
|
|
1535
|
+
repo: RepoReq | None = None,
|
|
1536
|
+
host_id: str = "public_host",
|
|
1537
|
+
image_id: str | None = None,
|
|
1538
|
+
model_id: str | None = None,
|
|
1539
|
+
resource: VMResource | None = None,
|
|
1540
|
+
task_type: TaskType = TaskType.DEVELOP,
|
|
1541
|
+
sub_type: TaskSubType | None = None,
|
|
1542
|
+
cli_name: CliName = CliName.OPENCODE,
|
|
1543
|
+
git_identity_id: str | None = None,
|
|
1544
|
+
system_prompt: str | None = None,
|
|
1545
|
+
skill_ids: list[str] | None = None,
|
|
1546
|
+
plugin_ids: list[str] | None = None,
|
|
1547
|
+
project_id: str | None = None,
|
|
1548
|
+
issue_id: str | None = None,
|
|
1549
|
+
attachments: list[dict] | None = None,
|
|
1550
|
+
) -> Task:
|
|
1551
|
+
"""创建任务。
|
|
1552
|
+
|
|
1553
|
+
必填:content。repo 可空(不绑定 Git 仓库也能创建任务);
|
|
1554
|
+
host_id 默认 public_host;image_id / model_id 留空时自动取列表第一个可用项;
|
|
1555
|
+
资源默认 2 核 / 8GB / 3 小时(桌面端默认档位)。
|
|
1556
|
+
attachments:附件列表(最多 10 个),每项 {url, filename}。
|
|
1557
|
+
"""
|
|
1558
|
+
repo = repo or RepoReq(repo_url="")
|
|
1559
|
+
if not image_id:
|
|
1560
|
+
images = self.list_images()
|
|
1561
|
+
if not images:
|
|
1562
|
+
raise TaskError("没有可用的镜像(images),请先在控制台创建镜像。")
|
|
1563
|
+
image_id = images[0]["id"]
|
|
1564
|
+
if not model_id:
|
|
1565
|
+
models = self.list_models()
|
|
1566
|
+
if not models:
|
|
1567
|
+
raise TaskError("没有可用的模型(models),请先在控制台配置模型。")
|
|
1568
|
+
model_id = models[0]["id"]
|
|
1569
|
+
|
|
1570
|
+
body: dict[str, Any] = {
|
|
1571
|
+
"content": content,
|
|
1572
|
+
"repo": repo.to_dict(),
|
|
1573
|
+
"host_id": str(host_id),
|
|
1574
|
+
"image_id": str(image_id),
|
|
1575
|
+
"model_id": str(model_id),
|
|
1576
|
+
"resource": (resource or VMResource()).to_dict(),
|
|
1577
|
+
"task_type": task_type.value,
|
|
1578
|
+
}
|
|
1579
|
+
if sub_type:
|
|
1580
|
+
body["sub_type"] = sub_type.value
|
|
1581
|
+
if cli_name:
|
|
1582
|
+
body["cli_name"] = cli_name.value
|
|
1583
|
+
if git_identity_id:
|
|
1584
|
+
body["git_identity_id"] = git_identity_id
|
|
1585
|
+
if system_prompt:
|
|
1586
|
+
body["system_prompt"] = system_prompt
|
|
1587
|
+
if attachments:
|
|
1588
|
+
body["attachments"] = attachments[:10]
|
|
1589
|
+
extra = TaskExtraConfig(
|
|
1590
|
+
skill_ids=skill_ids,
|
|
1591
|
+
plugin_ids=plugin_ids,
|
|
1592
|
+
project_id=project_id,
|
|
1593
|
+
issue_id=issue_id,
|
|
1594
|
+
).to_dict()
|
|
1595
|
+
if extra:
|
|
1596
|
+
body["extra"] = extra
|
|
1597
|
+
data = self._request("POST", "/api/v1/users/tasks", json=body)
|
|
1598
|
+
return Task.from_dict(data)
|
|
1599
|
+
|
|
1600
|
+
def list_tasks(
|
|
1601
|
+
self,
|
|
1602
|
+
page: int | None = None,
|
|
1603
|
+
size: int | None = None,
|
|
1604
|
+
status: str | None = None,
|
|
1605
|
+
project_id: str | None = None,
|
|
1606
|
+
quick_start: str | None = None,
|
|
1607
|
+
next_token: str | None = None,
|
|
1608
|
+
) -> Any:
|
|
1609
|
+
"""任务列表。
|
|
1610
|
+
|
|
1611
|
+
返回接口原始 data(通常为 {"tasks": [...], "page": {...}} 或列表),
|
|
1612
|
+
支持状态筛选:status 多值用逗号分隔,如 "pending,processing"。
|
|
1613
|
+
"""
|
|
1614
|
+
params: dict[str, Any] = {}
|
|
1615
|
+
if page is not None:
|
|
1616
|
+
params["page"] = page
|
|
1617
|
+
if size is not None:
|
|
1618
|
+
params["size"] = size
|
|
1619
|
+
if status is not None:
|
|
1620
|
+
params["status"] = status
|
|
1621
|
+
if project_id is not None:
|
|
1622
|
+
params["project_id"] = project_id
|
|
1623
|
+
if quick_start is not None:
|
|
1624
|
+
params["quick_start"] = quick_start
|
|
1625
|
+
if next_token is not None:
|
|
1626
|
+
params["next_token"] = next_token
|
|
1627
|
+
return self._request("GET", "/api/v1/users/tasks", params=params)
|
|
1628
|
+
|
|
1629
|
+
def get_task(self, task_id: str) -> Task:
|
|
1630
|
+
"""获取任务详情。"""
|
|
1631
|
+
data = self._request("GET", f"/api/v1/users/tasks/{task_id}")
|
|
1632
|
+
return Task.from_dict(data)
|
|
1633
|
+
|
|
1634
|
+
def get_task_rounds(
|
|
1635
|
+
self,
|
|
1636
|
+
task_id: str,
|
|
1637
|
+
cursor: str | None = None,
|
|
1638
|
+
limit: int = 2,
|
|
1639
|
+
direction: str = "backward",
|
|
1640
|
+
inclusive: bool | None = None,
|
|
1641
|
+
) -> Any:
|
|
1642
|
+
"""查询任务历史轮次。"""
|
|
1643
|
+
params: dict[str, Any] = {
|
|
1644
|
+
"id": task_id,
|
|
1645
|
+
"limit": limit,
|
|
1646
|
+
"direction": direction,
|
|
1647
|
+
}
|
|
1648
|
+
if cursor is not None:
|
|
1649
|
+
params["cursor"] = cursor
|
|
1650
|
+
if inclusive is not None:
|
|
1651
|
+
params["inclusive"] = inclusive
|
|
1652
|
+
return self._request("GET", "/api/v1/users/tasks/rounds", params=params)
|
|
1653
|
+
|
|
1654
|
+
def get_task_user_inputs(
|
|
1655
|
+
self,
|
|
1656
|
+
task_id: str,
|
|
1657
|
+
cursor: str | None = None,
|
|
1658
|
+
limit: int = 20,
|
|
1659
|
+
) -> Any:
|
|
1660
|
+
"""查询任务用户输入列表。"""
|
|
1661
|
+
params: dict[str, Any] = {"id": task_id, "limit": limit}
|
|
1662
|
+
if cursor is not None:
|
|
1663
|
+
params["cursor"] = cursor
|
|
1664
|
+
return self._request("GET", "/api/v1/users/tasks/user-inputs", params=params)
|
|
1665
|
+
|
|
1666
|
+
def stop_task(self, task_id: str) -> dict[str, Any]:
|
|
1667
|
+
"""停止任务。"""
|
|
1668
|
+
return self._request("PUT", "/api/v1/users/tasks/stop", json={"id": task_id})
|
|
1669
|
+
|
|
1670
|
+
def delete_task(self, task_id: str) -> Any:
|
|
1671
|
+
"""删除任务。"""
|
|
1672
|
+
return self._request("DELETE", f"/api/v1/users/tasks/{task_id}")
|
|
1673
|
+
|
|
1674
|
+
def wait_task(
|
|
1675
|
+
self,
|
|
1676
|
+
task_id: str,
|
|
1677
|
+
timeout: float = 1800.0,
|
|
1678
|
+
interval: float = 5.0,
|
|
1679
|
+
terminal_statuses: set[str] | None = None,
|
|
1680
|
+
) -> Task:
|
|
1681
|
+
"""轮询任务直到进入终态。
|
|
1682
|
+
|
|
1683
|
+
终态默认:success / failed / stopped / canceled。
|
|
1684
|
+
"""
|
|
1685
|
+
terminals = terminal_statuses or {
|
|
1686
|
+
"success",
|
|
1687
|
+
"failed",
|
|
1688
|
+
"stopped",
|
|
1689
|
+
"canceled",
|
|
1690
|
+
"cancelled",
|
|
1691
|
+
}
|
|
1692
|
+
deadline = time.monotonic() + timeout
|
|
1693
|
+
last = Task.from_dict({"id": task_id, "status": "running"})
|
|
1694
|
+
while time.monotonic() < deadline:
|
|
1695
|
+
last = self.get_task(task_id)
|
|
1696
|
+
if last.status.lower() in terminals:
|
|
1697
|
+
return last
|
|
1698
|
+
time.sleep(interval)
|
|
1699
|
+
raise TaskError(f"等待任务 {task_id} 超时({timeout}s),最后状态:{last.status}")
|
|
1700
|
+
|
|
1701
|
+
# ---------- 预览链接 ----------
|
|
1702
|
+
|
|
1703
|
+
def list_task_links(
|
|
1704
|
+
self, task_id: str, check_alive: bool = True, max_pages: int = 500
|
|
1705
|
+
) -> list[dict[str, Any]]:
|
|
1706
|
+
"""列出任务中 AI 拉起的预览链接,并检测存活状态。
|
|
1707
|
+
|
|
1708
|
+
返回 [{url, count, status, http_code}],按出现次数降序。
|
|
1709
|
+
status 取值:在线 / 异常 / 已关闭(check_alive=False 时为 None)。
|
|
1710
|
+
"""
|
|
1711
|
+
counts = collect_preview_links(self.get_task_rounds, task_id, max_pages)
|
|
1712
|
+
result: list[dict[str, Any]] = []
|
|
1713
|
+
for url, count in sorted(counts.items(), key=lambda x: -x[1]):
|
|
1714
|
+
status: str | None = None
|
|
1715
|
+
http_code: int | None = None
|
|
1716
|
+
if check_alive:
|
|
1717
|
+
status, http_code = check_url_status(url)
|
|
1718
|
+
result.append(
|
|
1719
|
+
{
|
|
1720
|
+
"url": url,
|
|
1721
|
+
"count": count,
|
|
1722
|
+
"status": status,
|
|
1723
|
+
"http_code": http_code,
|
|
1724
|
+
}
|
|
1725
|
+
)
|
|
1726
|
+
return result
|
|
1727
|
+
|
|
1728
|
+
# ---------- WebSocket 会话交互 ----------
|
|
1729
|
+
|
|
1730
|
+
def open_task_stream(self, task_id: str, mode: str = "new") -> TaskStream:
|
|
1731
|
+
"""打开任务实时流(WebSocket 上下文管理器)。
|
|
1732
|
+
|
|
1733
|
+
mode="new":等待用户输入(发送消息用);
|
|
1734
|
+
mode="attach":只拉取当前轮次历史 + 订阅实时流(旁观用)。
|
|
1735
|
+
"""
|
|
1736
|
+
return TaskStream(self, task_id, mode=mode)
|
|
1737
|
+
|
|
1738
|
+
async def send_task_message(
|
|
1739
|
+
self,
|
|
1740
|
+
task_id: str,
|
|
1741
|
+
content: str,
|
|
1742
|
+
attachments: list[dict] | None = None,
|
|
1743
|
+
read_timeout: float = 30.0,
|
|
1744
|
+
) -> list[TaskStreamChunk]:
|
|
1745
|
+
"""进入任务会话,发送用户消息,返回直到 task-started 的首批事件。
|
|
1746
|
+
|
|
1747
|
+
需要在 asyncio 事件循环中调用(AstrBot 插件环境天然支持)。
|
|
1748
|
+
"""
|
|
1749
|
+
stream = TaskStream(self, task_id, mode="new")
|
|
1750
|
+
await stream.connect()
|
|
1751
|
+
try:
|
|
1752
|
+
await stream.send_user_input(content, attachments)
|
|
1753
|
+
events: list[TaskStreamChunk] = []
|
|
1754
|
+
while True:
|
|
1755
|
+
chunk = await stream.recv(timeout=read_timeout)
|
|
1756
|
+
events.append(chunk)
|
|
1757
|
+
if chunk.type in ("task-started", "task-ended", "task-error"):
|
|
1758
|
+
break
|
|
1759
|
+
return events
|
|
1760
|
+
finally:
|
|
1761
|
+
await stream.close()
|
|
1762
|
+
|
|
1763
|
+
# ---------- 任务控制 / 选项 / 终端 ----------
|
|
1764
|
+
|
|
1765
|
+
def get_task_options(self) -> dict[str, Any]:
|
|
1766
|
+
"""聚合创建任务所需的下拉选项,一次返回。
|
|
1767
|
+
|
|
1768
|
+
对应桌面端 mc_task_options:聚合多个 REST 列表接口。
|
|
1769
|
+
返回 {models, images, hosts, projects, plan}。
|
|
1770
|
+
"""
|
|
1771
|
+
subscription = self.get_subscription()
|
|
1772
|
+
plan = ""
|
|
1773
|
+
if isinstance(subscription, dict):
|
|
1774
|
+
plan = subscription.get("plan") or ""
|
|
1775
|
+
return {
|
|
1776
|
+
"models": self.list_models(),
|
|
1777
|
+
"images": self.list_images(),
|
|
1778
|
+
"hosts": self.list_hosts(),
|
|
1779
|
+
"projects": self.list_projects(),
|
|
1780
|
+
"plan": plan,
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
def open_task_control(self, task_id: str) -> ControlStream:
|
|
1784
|
+
"""打开任务控制通道(WebSocket 上下文管理器)。
|
|
1785
|
+
|
|
1786
|
+
用于 switch_model / restart / 文件浏览 / 端口转发等同步调用。
|
|
1787
|
+
"""
|
|
1788
|
+
return ControlStream(self, task_id)
|
|
1789
|
+
|
|
1790
|
+
async def switch_task_model(
|
|
1791
|
+
self,
|
|
1792
|
+
task_id: str,
|
|
1793
|
+
model_id: str,
|
|
1794
|
+
load_session: bool = True,
|
|
1795
|
+
timeout: float = 30.0,
|
|
1796
|
+
) -> dict[str, Any]:
|
|
1797
|
+
"""切换运行中任务的模型(走 control 通道)。
|
|
1798
|
+
|
|
1799
|
+
成功时返回服务端响应 dict(含 session_id / model);
|
|
1800
|
+
失败时返回 {"success": False, "error": "..."}。
|
|
1801
|
+
"""
|
|
1802
|
+
stream = ControlStream(self, task_id)
|
|
1803
|
+
await stream.connect()
|
|
1804
|
+
try:
|
|
1805
|
+
await stream.send_call(
|
|
1806
|
+
"switch_model",
|
|
1807
|
+
{"model_id": str(model_id), "load_session": load_session},
|
|
1808
|
+
)
|
|
1809
|
+
while True:
|
|
1810
|
+
chunk = await stream.recv(timeout=timeout)
|
|
1811
|
+
if chunk.type == "call-response":
|
|
1812
|
+
data = chunk.data
|
|
1813
|
+
if isinstance(data, dict):
|
|
1814
|
+
return data
|
|
1815
|
+
return {"success": False, "error": str(data)}
|
|
1816
|
+
if chunk.type == "error":
|
|
1817
|
+
return {"success": False, "error": str(chunk.data)}
|
|
1818
|
+
finally:
|
|
1819
|
+
await stream.close()
|
|
1820
|
+
|
|
1821
|
+
def list_terminal_sessions(self, vm_id: str) -> list[dict[str, Any]]:
|
|
1822
|
+
"""获取虚拟机终端会话列表。"""
|
|
1823
|
+
data = self._request(
|
|
1824
|
+
"GET", f"/api/v1/users/hosts/vms/{vm_id}/terminals"
|
|
1825
|
+
)
|
|
1826
|
+
if isinstance(data, dict):
|
|
1827
|
+
return (
|
|
1828
|
+
data.get("terminals")
|
|
1829
|
+
or data.get("sessions")
|
|
1830
|
+
or data.get("list")
|
|
1831
|
+
or []
|
|
1832
|
+
)
|
|
1833
|
+
return data or []
|
|
1834
|
+
|
|
1835
|
+
def share_terminal(
|
|
1836
|
+
self,
|
|
1837
|
+
vm_id: str,
|
|
1838
|
+
mode: str = "read_write",
|
|
1839
|
+
terminal_id: str | None = None,
|
|
1840
|
+
) -> Any:
|
|
1841
|
+
"""分享终端,返回 {password}。
|
|
1842
|
+
|
|
1843
|
+
mode 支持 read_write / read_only;terminal_id 可选。
|
|
1844
|
+
"""
|
|
1845
|
+
body: dict[str, Any] = {"mode": mode}
|
|
1846
|
+
if terminal_id:
|
|
1847
|
+
body["terminal_id"] = terminal_id
|
|
1848
|
+
return self._request(
|
|
1849
|
+
"POST",
|
|
1850
|
+
f"/api/v1/users/hosts/vms/{vm_id}/terminals/share",
|
|
1851
|
+
json=body,
|
|
1852
|
+
)
|
|
1853
|
+
|
|
1854
|
+
def close_terminal(self, vm_id: str, terminal_id: str) -> Any:
|
|
1855
|
+
"""关闭指定终端会话。"""
|
|
1856
|
+
return self._request(
|
|
1857
|
+
"DELETE",
|
|
1858
|
+
f"/api/v1/users/hosts/vms/{vm_id}/terminals/{terminal_id}",
|
|
1859
|
+
)
|
|
1860
|
+
|
|
1861
|
+
def open_terminal(
|
|
1862
|
+
self,
|
|
1863
|
+
vm_id: str,
|
|
1864
|
+
terminal_id: str | None = None,
|
|
1865
|
+
col: int = 80,
|
|
1866
|
+
row: int = 24,
|
|
1867
|
+
) -> TerminalStream:
|
|
1868
|
+
"""打开虚拟机终端流(WebSocket 上下文管理器)。
|
|
1869
|
+
|
|
1870
|
+
终端输出/输入均为原始字节,data 消息自动 base64 解码。
|
|
1871
|
+
"""
|
|
1872
|
+
return TerminalStream(
|
|
1873
|
+
self, vm_id, terminal_id=terminal_id, col=col, row=row
|
|
1874
|
+
)
|
|
1875
|
+
|
|
1876
|
+
def join_terminal(
|
|
1877
|
+
self,
|
|
1878
|
+
terminal_id: str,
|
|
1879
|
+
password: str,
|
|
1880
|
+
col: int = 80,
|
|
1881
|
+
row: int = 24,
|
|
1882
|
+
) -> JoinTerminalStream:
|
|
1883
|
+
"""加入共享终端(WebSocket 上下文管理器)。
|
|
1884
|
+
|
|
1885
|
+
使用 share_terminal() 返回的分享密码加入他人终端。
|
|
1886
|
+
"""
|
|
1887
|
+
return JoinTerminalStream(
|
|
1888
|
+
self, terminal_id, password=password, col=col, row=row
|
|
1889
|
+
)
|
|
1890
|
+
|
|
1891
|
+
# ---------- 任务控制调用(WebSocket control 专用封装) ----------
|
|
1892
|
+
|
|
1893
|
+
async def _task_control_call(
|
|
1894
|
+
self,
|
|
1895
|
+
task_id: str,
|
|
1896
|
+
kind: str,
|
|
1897
|
+
data: dict[str, Any],
|
|
1898
|
+
timeout: float = 30.0,
|
|
1899
|
+
) -> dict[str, Any]:
|
|
1900
|
+
"""通用控制通道调用:发送 call 并等待 call-response。"""
|
|
1901
|
+
stream = ControlStream(self, task_id)
|
|
1902
|
+
await stream.connect()
|
|
1903
|
+
try:
|
|
1904
|
+
await stream.send_call(kind, data)
|
|
1905
|
+
while True:
|
|
1906
|
+
chunk = await stream.recv(timeout=timeout)
|
|
1907
|
+
if chunk.type == "call-response":
|
|
1908
|
+
result = chunk.data
|
|
1909
|
+
if isinstance(result, dict):
|
|
1910
|
+
return result
|
|
1911
|
+
return {"success": False, "error": str(result)}
|
|
1912
|
+
if chunk.type == "error":
|
|
1913
|
+
return {"success": False, "error": str(chunk.data)}
|
|
1914
|
+
finally:
|
|
1915
|
+
await stream.close()
|
|
1916
|
+
|
|
1917
|
+
async def switch_task_agent_resources(
|
|
1918
|
+
self,
|
|
1919
|
+
task_id: str,
|
|
1920
|
+
skill_ids: list[str] | None = None,
|
|
1921
|
+
plugin_ids: list[str] | None = None,
|
|
1922
|
+
timeout: float = 30.0,
|
|
1923
|
+
) -> dict[str, Any]:
|
|
1924
|
+
"""运行中全量更新任务的技能/插件列表(kind=switch_agent_resources)。"""
|
|
1925
|
+
return await self._task_control_call(
|
|
1926
|
+
task_id,
|
|
1927
|
+
"switch_agent_resources",
|
|
1928
|
+
{
|
|
1929
|
+
"skill_ids": skill_ids or [],
|
|
1930
|
+
"plugin_ids": plugin_ids or [],
|
|
1931
|
+
},
|
|
1932
|
+
timeout=timeout,
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
async def restart_task(
|
|
1936
|
+
self,
|
|
1937
|
+
task_id: str,
|
|
1938
|
+
load_session: bool = True,
|
|
1939
|
+
timeout: float = 30.0,
|
|
1940
|
+
) -> dict[str, Any]:
|
|
1941
|
+
"""重启任务,可选加载历史会话(kind=restart)。"""
|
|
1942
|
+
return await self._task_control_call(
|
|
1943
|
+
task_id,
|
|
1944
|
+
"restart",
|
|
1945
|
+
{"load_session": load_session},
|
|
1946
|
+
timeout=timeout,
|
|
1947
|
+
)
|
|
1948
|
+
|
|
1949
|
+
async def list_task_repo_files(
|
|
1950
|
+
self,
|
|
1951
|
+
task_id: str,
|
|
1952
|
+
path: str = "",
|
|
1953
|
+
glob_pattern: str = "*",
|
|
1954
|
+
include_hidden: bool = True,
|
|
1955
|
+
timeout: float = 30.0,
|
|
1956
|
+
) -> dict[str, Any]:
|
|
1957
|
+
"""列出任务仓库文件(kind=repo_file_list)。"""
|
|
1958
|
+
return await self._task_control_call(
|
|
1959
|
+
task_id,
|
|
1960
|
+
"repo_file_list",
|
|
1961
|
+
{
|
|
1962
|
+
"path": path,
|
|
1963
|
+
"glob_pattern": glob_pattern,
|
|
1964
|
+
"include_hidden": include_hidden,
|
|
1965
|
+
},
|
|
1966
|
+
timeout=timeout,
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
async def read_task_repo_file(
|
|
1970
|
+
self,
|
|
1971
|
+
task_id: str,
|
|
1972
|
+
path: str,
|
|
1973
|
+
offset: int = 0,
|
|
1974
|
+
length: int = 1024 * 1024,
|
|
1975
|
+
timeout: float = 30.0,
|
|
1976
|
+
) -> dict[str, Any]:
|
|
1977
|
+
"""读取任务仓库文件(kind=repo_read_file,默认前 1MB)。"""
|
|
1978
|
+
return await self._task_control_call(
|
|
1979
|
+
task_id,
|
|
1980
|
+
"repo_read_file",
|
|
1981
|
+
{"path": path, "offset": offset, "length": length},
|
|
1982
|
+
timeout=timeout,
|
|
1983
|
+
)
|
|
1984
|
+
|
|
1985
|
+
async def get_task_repo_diff(
|
|
1986
|
+
self, task_id: str, path: str, timeout: float = 30.0
|
|
1987
|
+
) -> dict[str, Any]:
|
|
1988
|
+
"""获取任务仓库文件 diff(kind=repo_file_diff)。"""
|
|
1989
|
+
return await self._task_control_call(
|
|
1990
|
+
task_id, "repo_file_diff", {"path": path}, timeout=timeout
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
async def get_task_repo_changes(
|
|
1994
|
+
self, task_id: str, timeout: float = 30.0
|
|
1995
|
+
) -> dict[str, Any]:
|
|
1996
|
+
"""获取任务仓库变更列表(kind=repo_file_changes)。"""
|
|
1997
|
+
return await self._task_control_call(
|
|
1998
|
+
task_id, "repo_file_changes", {}, timeout=timeout
|
|
1999
|
+
)
|
|
2000
|
+
|
|
2001
|
+
async def sync_client_ip(
|
|
2002
|
+
self, task_id: str, ip: str, timeout: float = 30.0
|
|
2003
|
+
) -> dict[str, Any]:
|
|
2004
|
+
"""上报客户端 IP(控制通道上行消息 sync-my-ip,Web 前端用)。
|
|
2005
|
+
|
|
2006
|
+
通过 ControlStream.send_raw 发送非 call 消息;返回收到的
|
|
2007
|
+
下一条事件(可能是 call-response / error / 心跳)。
|
|
2008
|
+
"""
|
|
2009
|
+
stream = ControlStream(self, task_id)
|
|
2010
|
+
await stream.connect()
|
|
2011
|
+
try:
|
|
2012
|
+
inner = json.dumps({"ip": ip}, ensure_ascii=False)
|
|
2013
|
+
await stream.send_raw("sync-my-ip", b64e(inner))
|
|
2014
|
+
chunk = await stream.recv(timeout=timeout)
|
|
2015
|
+
return {"success": chunk.type != "error", "event": chunk.raw}
|
|
2016
|
+
finally:
|
|
2017
|
+
await stream.close()
|
|
2018
|
+
|
|
2019
|
+
# ---------- OhMyAgent API Key ----------
|
|
2020
|
+
|
|
2021
|
+
def create_api_key(self) -> APIKeyInfo:
|
|
2022
|
+
"""创建 OhMyAgent API Key。
|
|
2023
|
+
|
|
2024
|
+
注意:明文 api_key 仅在本次响应中返回,调用方应立即安全保存。
|
|
2025
|
+
该 Key 用于 OhMyAgent 代理(LLM 请求的 model 字段传模型配置 ID),
|
|
2026
|
+
不是 REST API 的会话凭证。
|
|
2027
|
+
"""
|
|
2028
|
+
data = self._request("POST", "/api/v1/users/ohmyagent/api-keys", json={})
|
|
2029
|
+
return APIKeyInfo.from_dict(data)
|
|
2030
|
+
|
|
2031
|
+
def delete_api_key(self, key_id: str) -> Any:
|
|
2032
|
+
"""按 Key ID 删除当前用户的 OhMyAgent API Key。"""
|
|
2033
|
+
return self._request("DELETE", f"/api/v1/users/ohmyagent/api-keys/{key_id}")
|
|
2034
|
+
|
|
2035
|
+
# ---------- 内部请求 ----------
|
|
2036
|
+
|
|
2037
|
+
def _request(
|
|
2038
|
+
self,
|
|
2039
|
+
method: str,
|
|
2040
|
+
path: str,
|
|
2041
|
+
json: dict[str, Any] | None = None,
|
|
2042
|
+
params: dict[str, Any] | None = None,
|
|
2043
|
+
auth_required: bool = True,
|
|
2044
|
+
files: dict[str, Any] | None = None,
|
|
2045
|
+
_retry: bool = True,
|
|
2046
|
+
_attempt: int = 0,
|
|
2047
|
+
) -> Any:
|
|
2048
|
+
try:
|
|
2049
|
+
resp = self._client.request(
|
|
2050
|
+
method, path, json=json, params=params, files=files
|
|
2051
|
+
)
|
|
2052
|
+
except httpx.HTTPError as exc:
|
|
2053
|
+
# 网络抖动:只读请求自动重试(指数退避)
|
|
2054
|
+
if method.upper() in _RETRYABLE_METHODS and _attempt < _MAX_RETRIES:
|
|
2055
|
+
time.sleep(_RETRY_BASE_DELAY * (2 ** _attempt))
|
|
2056
|
+
return self._request(
|
|
2057
|
+
method,
|
|
2058
|
+
path,
|
|
2059
|
+
json=json,
|
|
2060
|
+
params=params,
|
|
2061
|
+
auth_required=auth_required,
|
|
2062
|
+
files=files,
|
|
2063
|
+
_retry=_retry,
|
|
2064
|
+
_attempt=_attempt + 1,
|
|
2065
|
+
)
|
|
2066
|
+
raise APIError(f"请求失败:{exc}") from exc
|
|
2067
|
+
|
|
2068
|
+
if resp.status_code == 401:
|
|
2069
|
+
if (
|
|
2070
|
+
_retry
|
|
2071
|
+
and auth_required
|
|
2072
|
+
and self._auto_relogin
|
|
2073
|
+
and not self._relogging
|
|
2074
|
+
and self.email
|
|
2075
|
+
and self._password
|
|
2076
|
+
):
|
|
2077
|
+
# 会话过期:自动重新登录后重试原请求一次
|
|
2078
|
+
self._relogging = True
|
|
2079
|
+
try:
|
|
2080
|
+
self.login()
|
|
2081
|
+
except AuthenticationError:
|
|
2082
|
+
pass
|
|
2083
|
+
finally:
|
|
2084
|
+
self._relogging = False
|
|
2085
|
+
if self._logged_in:
|
|
2086
|
+
return self._request(
|
|
2087
|
+
method,
|
|
2088
|
+
path,
|
|
2089
|
+
json=json,
|
|
2090
|
+
params=params,
|
|
2091
|
+
auth_required=auth_required,
|
|
2092
|
+
files=files,
|
|
2093
|
+
_retry=False,
|
|
2094
|
+
)
|
|
2095
|
+
raise AuthenticationError("未认证或会话已失效,请重新登录。", code=401)
|
|
2096
|
+
if resp.status_code == 429:
|
|
2097
|
+
# 限流:只读请求按 Retry-After / 指数退避等待后重试
|
|
2098
|
+
if method.upper() in _RETRYABLE_METHODS and _attempt < _MAX_RETRIES:
|
|
2099
|
+
retry_after = resp.headers.get("Retry-After")
|
|
2100
|
+
delay = _RETRY_BASE_DELAY * (2 ** _attempt)
|
|
2101
|
+
if retry_after and retry_after.isdigit():
|
|
2102
|
+
delay = max(int(retry_after), 1)
|
|
2103
|
+
time.sleep(delay)
|
|
2104
|
+
return self._request(
|
|
2105
|
+
method,
|
|
2106
|
+
path,
|
|
2107
|
+
json=json,
|
|
2108
|
+
params=params,
|
|
2109
|
+
auth_required=auth_required,
|
|
2110
|
+
files=files,
|
|
2111
|
+
_retry=_retry,
|
|
2112
|
+
_attempt=_attempt + 1,
|
|
2113
|
+
)
|
|
2114
|
+
raise RateLimitError("触发限流(HTTP 429),请稍后重试。", code=429)
|
|
2115
|
+
if resp.status_code == 403 and auth_required:
|
|
2116
|
+
raise AuthenticationError("无权限访问该资源。", code=403)
|
|
2117
|
+
|
|
2118
|
+
try:
|
|
2119
|
+
body = resp.json()
|
|
2120
|
+
except ValueError:
|
|
2121
|
+
if resp.status_code != 200:
|
|
2122
|
+
raise APIError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
2123
|
+
return resp.text
|
|
2124
|
+
|
|
2125
|
+
if isinstance(body, dict) and "code" in body:
|
|
2126
|
+
code = body.get("code")
|
|
2127
|
+
if code != 0:
|
|
2128
|
+
message = body.get("message") or body.get("msg") or "未知错误"
|
|
2129
|
+
message = _friendly_error(code, message)
|
|
2130
|
+
raise APIError(f"{message}(code={code})", code=code, raw=body)
|
|
2131
|
+
return body.get("data")
|
|
2132
|
+
return body
|
|
2133
|
+
|
|
2134
|
+
def close(self) -> None:
|
|
2135
|
+
self._client.close()
|
|
2136
|
+
|
|
2137
|
+
def __enter__(self) -> "MonkeyCodeClient":
|
|
2138
|
+
return self
|
|
2139
|
+
|
|
2140
|
+
def __exit__(self, *exc: Any) -> None:
|
|
2141
|
+
self.close()
|