cheso-cli 0.2.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.
cheso_cli/tasks.py ADDED
@@ -0,0 +1,834 @@
1
+ """任务(Plan)、转换和翻译:发起、等待、查询、回答、取消及获取结果。
2
+
3
+ 各函数返回普通字典作为结果视图,由 CLI 输出为 JSON,或格式化后展示给用户。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import posixpath
9
+ import re
10
+ import shlex
11
+ import sys
12
+ import time
13
+ from collections.abc import Callable
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, TextIO
17
+
18
+ import httpx
19
+
20
+ from cheso_cli.client import ApiError, ChesoClient, Deadline, TimeLimitReached
21
+ from cheso_cli.config import Settings
22
+ from cheso_cli.errors import CliError, ExitCode, UsageError
23
+ from cheso_cli.transfer import download_url_to_dir, upload_file
24
+
25
+ KIND_TASK = "task"
26
+ KIND_CONVERSION = "conversion"
27
+ KIND_TRANSLATION = "translation"
28
+ # Manager 的 typeid 前缀。
29
+ ID_PREFIXES = {"plan_": KIND_TASK, "cvt_": KIND_CONVERSION, "tlj_": KIND_TRANSLATION}
30
+
31
+ PLAN_INTERACT = "interact"
32
+ PLAN_COMPLETED = "completed"
33
+ PLAN_TERMINAL = {"completed", "failed", "terminated", "rolled_back"}
34
+ PLAN_RUNNING = {"in_progress", "interact", "pending"}
35
+ CONVERT_COMPLETED = "completed"
36
+ CONVERT_DONE = {"completed", "failed"}
37
+ TRANSLATION_SUCCEEDED = "SUCCEEDED"
38
+ TRANSLATION_DONE = {"SUCCEEDED", "FAILED", "CANCELED"}
39
+ # 翻译过程中的中间产物,例如结构化文本块和提取的图片,不作为最终交付文件。
40
+ TRANSLATION_SKIP_ROLES = {"blocks", "image"}
41
+
42
+ # Agent 可能将执行交给后续 Plan,最多沿接续链跟进以下跳数。
43
+ MAX_CHAIN_HOPS = 10
44
+ DEFAULT_POLL_INTERVAL = 3.0
45
+ DEFAULT_WAIT_TIMEOUT = 1800.0
46
+ HEARTBEAT_SECONDS = 60.0
47
+ # 等待期间允许连续出现的临时错误次数,包括网络错误、5xx 和 429。
48
+ MAX_TRANSIENT_ERRORS = 5
49
+
50
+ CODE_PROJECT_BUSY = 2614
51
+ CODE_CONVERT_UNSUPPORTED = 1801
52
+ AGENT_TYPES = ("cheso", "image_edit")
53
+ # 网站支持的目标语言,见 services/frontend/src/config/translation-languages.ts。
54
+ TRANSLATION_LANGUAGES = ("zh", "zh-Hant", "en", "ko", "ja", "es", "fr", "ar", "ru", "pt", "de", "th")
55
+ # 按网站 convert-download-request.ts 的规则由扩展名确定源类型;其他类型直接使用扩展名。
56
+ CONVERT_SOURCE_BY_EXT = {"md": "md", "markdown": "md", "html": "html", "htm": "html", "ppt": "pptx", "pptx": "pptx"}
57
+ # Agent 积分不足时,失败原因中会包含以下任一标记。
58
+ CREDIT_FAILURE_MARKERS = ("insufficient_credits", "insufficient credits", "额度不足", "积分不足")
59
+
60
+ _ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
61
+ _FRACTION = re.compile(r"\.(\d+)")
62
+
63
+
64
+ # 任务、转换或翻译成功时对应的状态。
65
+ SUCCESS_STATUS = {
66
+ KIND_TASK: PLAN_COMPLETED,
67
+ KIND_CONVERSION: CONVERT_COMPLETED,
68
+ KIND_TRANSLATION: TRANSLATION_SUCCEEDED,
69
+ }
70
+
71
+
72
+ def succeeded(view: dict[str, Any]) -> bool:
73
+ return bool(view.get("done")) and view.get("status") == SUCCESS_STATUS.get(view.get("kind", ""))
74
+
75
+
76
+ def validate_id(label: str, value: str) -> str:
77
+ value = (value or "").strip()
78
+ if not _ID_PATTERN.match(value):
79
+ raise UsageError(f"{label} {value!r} is not a valid Cheso id")
80
+ return value
81
+
82
+
83
+ def task_kind(task_id: str) -> str:
84
+ task_id = validate_id("task id", task_id)
85
+ for prefix, kind in ID_PREFIXES.items():
86
+ if task_id.startswith(prefix):
87
+ return kind
88
+ raise UsageError(f"unknown task id {task_id!r}: expected plan_..., cvt_... or tlj_...")
89
+
90
+
91
+ def parse_time(value: Any) -> datetime | None:
92
+ """解析 Go 输出的 RFC 3339 时间,支持任意长度的小数秒及 ``Z``;零值时间 ``0001-01-01`` 表示未设置。"""
93
+ if not isinstance(value, str) or not value.strip():
94
+ return None
95
+ text = value.strip()
96
+ if text.endswith(("Z", "z")):
97
+ text = text[:-1] + "+00:00"
98
+ text = _FRACTION.sub(lambda m: "." + (m.group(1) + "000000")[:6], text, count=1)
99
+ try:
100
+ parsed = datetime.fromisoformat(text)
101
+ except ValueError:
102
+ return None
103
+ if parsed.tzinfo is None:
104
+ parsed = parsed.replace(tzinfo=timezone.utc)
105
+ return None if parsed.year <= 1 else parsed
106
+
107
+
108
+ def available_credits(data: Any, now: datetime) -> int:
109
+ """汇总当前有效积分授权中的 ``amount_available``。"""
110
+ total = 0
111
+ grants = (data or {}).get("grants") if isinstance(data, dict) else None
112
+ for grant in grants or []:
113
+ if not isinstance(grant, dict):
114
+ continue
115
+ valid_from = parse_time(grant.get("valid_from"))
116
+ expires_at = parse_time(grant.get("expires_at"))
117
+ if valid_from and valid_from > now:
118
+ continue
119
+ if expires_at and expires_at <= now:
120
+ continue
121
+ try:
122
+ amount = int(grant.get("amount_available") or 0)
123
+ except (TypeError, ValueError):
124
+ continue
125
+ if amount > 0:
126
+ total += amount
127
+ return total
128
+
129
+
130
+ def is_credit_failure(reason: str) -> bool:
131
+ lower = (reason or "").lower()
132
+ return any(marker in lower for marker in CREDIT_FAILURE_MARKERS)
133
+
134
+
135
+ def question_from(interaction: Any) -> dict[str, Any] | None:
136
+ """将 Agent 提问(user_interact 数据)整理为精简结构;不认识的结构通过 ``raw`` 原样保留。"""
137
+ if not isinstance(interaction, dict) or not interaction:
138
+ return None
139
+ questions = interaction.get("questions")
140
+ if not isinstance(questions, list) or not questions:
141
+ return {"tool_call_id": str(interaction.get("tool_call_id") or ""), "questions": [], "raw": interaction}
142
+ items = []
143
+ for q in questions:
144
+ if not isinstance(q, dict):
145
+ continue
146
+ options = [
147
+ {"label": str(o.get("label") or ""), "description": str(o.get("description") or "")}
148
+ for o in q.get("options") or []
149
+ if isinstance(o, dict)
150
+ ]
151
+ items.append(
152
+ {
153
+ "question": str(q.get("question") or ""),
154
+ "header": str(q.get("header") or ""),
155
+ "options": options,
156
+ "multi_select": bool(q.get("multi_select")),
157
+ }
158
+ )
159
+ out: dict[str, Any] = {"tool_call_id": str(interaction.get("tool_call_id") or ""), "questions": items}
160
+ if isinstance(interaction.get("timeout"), (int, float)):
161
+ out["timeout_seconds"] = interaction["timeout"]
162
+ return out
163
+
164
+
165
+ def parse_answers(pairs: list[str], question: dict[str, Any] | None) -> dict[str, str]:
166
+ """将 ``--answer`` 参数转换为 ``{问题文本: 回答}``。
167
+
168
+ 支持 ``QUESTION=ANSWER``(精确问题文本,其中也可能包含 ``=``)、``N=ANSWER``(从 1 开始的问题编号)、
169
+ ``HEADER=ANSWER``(问题的简短标题);只有一个问题时,也支持直接传入 ``ANSWER``。
170
+ """
171
+ items = (question or {}).get("questions") or []
172
+ texts = [q["question"] for q in items]
173
+ answers: dict[str, str] = {}
174
+ for raw in pairs:
175
+ key = value = None
176
+ for index, char in enumerate(raw):
177
+ if char == "=" and raw[:index].strip() in texts:
178
+ key, value = raw[:index].strip(), raw[index + 1 :].strip()
179
+ break
180
+ if key is None and "=" in raw:
181
+ left, value = (part.strip() for part in raw.split("=", 1))
182
+ if left.isdigit() and 1 <= int(left) <= len(texts):
183
+ key = texts[int(left) - 1]
184
+ else:
185
+ matches = [q["question"] for q in items if q["header"] and q["header"].casefold() == left.casefold()]
186
+ if len(matches) == 1:
187
+ key = matches[0]
188
+ elif not texts:
189
+ key = left
190
+ if key is None and "=" not in raw and len(texts) == 1:
191
+ key, value = texts[0], raw.strip()
192
+ if key is None or value is None:
193
+ listed = "; ".join(f"{i}. {t}" for i, t in enumerate(texts, 1)) or "(none)"
194
+ raise UsageError(
195
+ f"--answer {raw!r} does not match a question. Use QUESTION=ANSWER or N=ANSWER. Questions: {listed}"
196
+ )
197
+ answers[key] = value
198
+ return answers
199
+
200
+
201
+ def _detail_str(data: Any, key: str) -> str:
202
+ value = data.get(key) if isinstance(data, dict) else None
203
+ return str(value) if value is not None else ""
204
+
205
+
206
+ class Tasks:
207
+ """使用同一站点和令牌操作任务、转换及翻译。"""
208
+
209
+ def __init__(
210
+ self,
211
+ client: ChesoClient,
212
+ settings: Settings,
213
+ storage: httpx.Client,
214
+ *,
215
+ progress: TextIO | None = None,
216
+ sleep: Callable[[float], None] = time.sleep,
217
+ clock: Callable[[], float] = time.monotonic,
218
+ now: Callable[[], float] = time.time,
219
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
220
+ ) -> None:
221
+ self.client = client
222
+ self.settings = settings
223
+ self.storage = storage
224
+ self.progress = progress or sys.stderr
225
+ self.sleep = sleep
226
+ self.clock = clock
227
+ self.now = now
228
+ self.poll_interval = poll_interval
229
+ # 转换详情中已有的带签名结果 URL,以结果文件 ID 为键保存。
230
+ self._known_urls: dict[str, str] = {}
231
+
232
+ def say(self, message: str) -> None:
233
+ print(message, file=self.progress, flush=True)
234
+
235
+ # ---- 积分 ----
236
+
237
+ def credits(self) -> dict[str, Any]:
238
+ data = self.client.credit_grants()
239
+ now = datetime.fromtimestamp(self.now(), tz=timezone.utc)
240
+ return {"available": available_credits(data, now), "topup_url": self.settings.topup_url}
241
+
242
+ def ensure_credits(self) -> None:
243
+ """账号没有积分时,在发起任务前停止,避免服务器执行到一半才失败。
244
+ 查询余额失败不会阻止发起任务,只会跳过这次提前检查。
245
+ """
246
+ try:
247
+ info = self.credits()
248
+ except ApiError as e:
249
+ if e.http_status == 401:
250
+ raise
251
+ self.say(f"warning: could not check the credit balance ({e}); starting the task anyway")
252
+ return
253
+ except httpx.TransportError:
254
+ self.say("warning: could not check the credit balance; starting the task anyway")
255
+ return
256
+ if info["available"] <= 0:
257
+ raise CliError(
258
+ f"Your Cheso account has no credits left, so the task was not started. Top up at {info['topup_url']}",
259
+ exit_code=ExitCode.INSUFFICIENT_CREDITS,
260
+ kind="insufficient_credits",
261
+ extra={"available": info["available"], "topup_url": info["topup_url"]},
262
+ )
263
+
264
+ # ---- 创建 ----
265
+
266
+ def create_task(
267
+ self,
268
+ *,
269
+ prompt: str,
270
+ paths: list[Path],
271
+ file_ids: list[str],
272
+ project_id: str | None,
273
+ agent_type: str | None,
274
+ ) -> dict[str, Any]:
275
+ prompt = (prompt or "").strip()
276
+ if not prompt:
277
+ raise UsageError("--prompt must not be empty")
278
+ project_id = validate_id("project id", project_id) if project_id else None
279
+ ids = [validate_id("file id", f) for f in file_ids]
280
+ for path in paths:
281
+ if not path.is_file():
282
+ raise UsageError(f"not a file: {path}")
283
+ self.ensure_credits()
284
+ uploads = []
285
+ for path in paths:
286
+ self.say(f"Uploading {path} ...")
287
+ result = upload_file(self.client, self.storage, path, project_id=project_id, now=self.now)
288
+ uploads.append(result.to_dict())
289
+ ids.append(result.file_id)
290
+ contexts = [{"file_id": file_id, "category": "attachment"} for file_id in dict.fromkeys(ids)]
291
+ try:
292
+ data = self.client.plan_create(
293
+ prompt=prompt, project_id=project_id, contexts=contexts or None, agent_type=agent_type or None
294
+ )
295
+ except ApiError as e:
296
+ if e.code == CODE_PROJECT_BUSY and project_id:
297
+ raise self.project_busy(project_id) from e
298
+ raise
299
+ data = data or {}
300
+ plan_id = _detail_str(data, "plan_id")
301
+ if not plan_id:
302
+ raise CliError("the task was not started (no task id returned)")
303
+ new_project = _detail_str(data, "project_id") or project_id or ""
304
+ view: dict[str, Any] = {
305
+ "task_id": plan_id,
306
+ "kind": KIND_TASK,
307
+ "status": _detail_str(data, "plan_status") or "in_progress",
308
+ "done": False,
309
+ "needs_input": False,
310
+ "project_id": new_project,
311
+ "project_url": self.settings.project_url(new_project),
312
+ }
313
+ if uploads:
314
+ view["uploaded_files"] = uploads
315
+ view["next"] = self.settings.command(f"task wait {plan_id}")
316
+ return view
317
+
318
+ def project_busy(self, project_id: str) -> CliError:
319
+ running: dict[str, Any] | None = None
320
+ try:
321
+ data = self.client.plan_list(project_id=project_id) or {}
322
+ for plan in data.get("plans") or []:
323
+ if isinstance(plan, dict) and plan.get("status") in PLAN_RUNNING:
324
+ running = plan
325
+ break
326
+ except (ApiError, httpx.TransportError):
327
+ running = None
328
+ extra: dict[str, Any] = {"project_id": project_id}
329
+ if running:
330
+ task_id = str(running.get("plan_id") or "")
331
+ extra["running_task_id"] = task_id
332
+ message = (
333
+ f"Project {project_id} already has a running task {task_id} (status {running.get('status')}). "
334
+ f"Wait for it with `{self.settings.command(f'task wait {task_id}')}` "
335
+ f"(or cancel it with `{self.settings.command(f'task cancel {task_id}')}`), "
336
+ "then retry."
337
+ )
338
+ else:
339
+ message = f"Project {project_id} already has a running task. Wait for it to finish, then retry."
340
+ return CliError(message, exit_code=ExitCode.RETRYABLE, kind="project_busy", extra=extra)
341
+
342
+ # ---- Plan 接续链 ----
343
+
344
+ def _follow(self, plan_id: str, budget: int) -> tuple[dict[str, Any], int, bool]:
345
+ """沿 ``next_plan_id`` 跟进至最后一个 Plan,最多 ``budget`` 跳;返回 (result, hops, chain_ended)。"""
346
+ current = plan_id
347
+ hops = 0
348
+ while True:
349
+ result = self.client.plan_result(current) or {}
350
+ following = _detail_str(result, "next_plan_id")
351
+ if not following:
352
+ return result, hops, True
353
+ if hops >= budget:
354
+ return result, hops, False
355
+ current = validate_id("next task id", following)
356
+ hops += 1
357
+
358
+ def _plan_view(self, requested_id: str, result: dict[str, Any], chain_ended: bool) -> dict[str, Any]:
359
+ plan_id = _detail_str(result, "plan_id") or requested_id
360
+ status = _detail_str(result, "status")
361
+ following = _detail_str(result, "next_plan_id")
362
+ needs_input = status == PLAN_INTERACT
363
+ done = status in PLAN_TERMINAL and not following
364
+ view: dict[str, Any] = {
365
+ "task_id": plan_id,
366
+ "kind": KIND_TASK,
367
+ "status": status,
368
+ "done": done,
369
+ "needs_input": needs_input,
370
+ }
371
+ if plan_id != requested_id:
372
+ view["continued_from"] = requested_id
373
+ try:
374
+ detail = self.client.plan_get(plan_id) or {}
375
+ except ApiError as e:
376
+ if e.http_status == 401:
377
+ raise
378
+ detail = {}
379
+ except httpx.TransportError:
380
+ detail = {}
381
+ project_id = _detail_str(detail, "project_id")
382
+ if project_id:
383
+ view["project_id"] = project_id
384
+ view["project_url"] = self.settings.project_url(project_id)
385
+ if _detail_str(detail, "summary"):
386
+ view["title"] = detail["summary"]
387
+ body = result.get("result") if isinstance(result.get("result"), dict) else None
388
+ if body:
389
+ view["answer"] = str(body.get("text") or "")
390
+ if body.get("text_truncated"):
391
+ view["answer_truncated"] = True
392
+ view["files"] = [self._plan_file(f) for f in body.get("files") or [] if isinstance(f, dict)]
393
+ if needs_input:
394
+ view["question"] = question_from(result.get("interaction"))
395
+ reason = _detail_str(result, "reason")
396
+ if status in PLAN_TERMINAL and status != PLAN_COMPLETED and reason:
397
+ view["failure_reason"] = reason
398
+ if done and status != PLAN_COMPLETED and is_credit_failure(reason):
399
+ view["topup_url"] = self.settings.topup_url
400
+ if following and not chain_ended:
401
+ view["next_task_id"] = following
402
+ view["next"] = self._plan_next(view)
403
+ return view
404
+
405
+ @staticmethod
406
+ def _plan_file(raw: dict[str, Any]) -> dict[str, Any]:
407
+ keys = ("file_id", "name", "path", "title", "description", "mime_type", "size", "version")
408
+ item = {k: raw[k] for k in keys if raw.get(k) not in (None, "")}
409
+ if not item.get("name") and item.get("path"):
410
+ item["name"] = posixpath.basename(str(item["path"]))
411
+ return item
412
+
413
+ def _plan_next(self, view: dict[str, Any]) -> str:
414
+ task_id = view.get("next_task_id") or view["task_id"]
415
+ if view.get("needs_input"):
416
+ return self.settings.command(f'task reply {view["task_id"]} --answer "QUESTION=ANSWER"') + " (or --skip)"
417
+ if not view["done"]:
418
+ return self.settings.command(f"task wait {task_id}")
419
+ if view["status"] == PLAN_COMPLETED:
420
+ project = view.get("project_id")
421
+ return (
422
+ self.settings.command(f'task create --project {project} --prompt "..."') + " to revise"
423
+ if project
424
+ else ""
425
+ )
426
+ return ""
427
+
428
+ # ---- 查询 / 等待 ----
429
+
430
+ def get(self, task_id: str) -> dict[str, Any]:
431
+ kind = task_kind(task_id)
432
+ if kind == KIND_TASK:
433
+ result, _, ended = self._follow(task_id, MAX_CHAIN_HOPS)
434
+ return self._plan_view(task_id, result, ended)
435
+ if kind == KIND_CONVERSION:
436
+ return self._conversion_view(self.client.convert_get(task_id) or {})
437
+ return self._translation_view(self.client.translation_get(task_id) or {})
438
+
439
+ def wait(
440
+ self, task_id: str, *, timeout: float, output_dir: Path | None, started: float | None = None
441
+ ) -> dict[str, Any]:
442
+ """持续轮询,直到任务结束、等待用户回答,或从 ``started``(默认当前时刻)起经过 ``timeout`` 秒。
443
+ 已结束任务的结果文件在同一时限内下载到 ``output_dir``,为 None 时跳过下载。
444
+ 截止时间后不再发起请求,每次请求最多使用剩余时间。
445
+ """
446
+ kind = task_kind(task_id)
447
+ started = self.clock() if started is None else started
448
+ deadline = Deadline(started + max(timeout, 0.0), self.clock)
449
+ with self.client.time_limit(deadline):
450
+ view = self._poll(kind, task_id, started, deadline)
451
+ if view["done"] and output_dir is not None:
452
+ self.download_results(view, output_dir, deadline=deadline)
453
+ return view
454
+
455
+ def _poll(self, kind: str, task_id: str, started: float, deadline: Deadline) -> dict[str, Any]:
456
+ current, hops_left = task_id, MAX_CHAIN_HOPS
457
+ last_status = ""
458
+ last_report = started
459
+ transient = 0
460
+ snapshot: Any = None
461
+ while True:
462
+ try:
463
+ if kind == KIND_TASK:
464
+ result, hops, ended = self._follow(current, hops_left)
465
+ hops_left -= hops
466
+ current = _detail_str(result, "plan_id") or current
467
+ status = _detail_str(result, "status")
468
+ following = _detail_str(result, "next_plan_id")
469
+ settled = status == PLAN_INTERACT or (status in PLAN_TERMINAL and not following)
470
+ if not ended:
471
+ settled = True # 接续跳数已用完,返回当前结果,让调用方从此处继续。
472
+ snapshot = (result, ended)
473
+ elif kind == KIND_CONVERSION:
474
+ snapshot = self.client.convert_get(task_id) or {}
475
+ status = _detail_str(snapshot, "status")
476
+ settled = status in CONVERT_DONE
477
+ else:
478
+ snapshot = self.client.translation_get(task_id) or {}
479
+ status = _detail_str(snapshot, "status")
480
+ settled = status in TRANSLATION_DONE
481
+ transient = 0
482
+ except (ApiError, httpx.TransportError) as e:
483
+ if isinstance(e, TimeLimitReached) or (isinstance(e, httpx.TransportError) and deadline.expired()):
484
+ break # 时间已到:抛出 TimeLimitReached,或请求被剩余时限截断。
485
+ if not _transient(e) or transient >= MAX_TRANSIENT_ERRORS:
486
+ raise
487
+ transient += 1
488
+ retry_after = e.retry_after if isinstance(e, ApiError) and e.retry_after else self.poll_interval
489
+ self.say(f"[{_elapsed(self.clock() - started)}] temporary error ({_brief(e)}); retrying")
490
+ if self.clock() + retry_after > deadline.at:
491
+ break
492
+ self.sleep(retry_after)
493
+ continue
494
+ elapsed = self.clock() - started
495
+ label = current if kind == KIND_TASK else task_id
496
+ if status != last_status:
497
+ self.say(f"[{_elapsed(elapsed)}] {label}: {status or 'unknown'}")
498
+ last_status, last_report = status, self.clock()
499
+ elif self.clock() - last_report >= HEARTBEAT_SECONDS:
500
+ self.say(f"[{_elapsed(elapsed)}] {label}: still {status} ...")
501
+ last_report = self.clock()
502
+ if settled:
503
+ return self._view_of(kind, task_id, snapshot)
504
+ if deadline.at - self.clock() <= self.poll_interval:
505
+ break # 已没有时间再轮询一次,剩余时间用于补齐返回结果的详情。
506
+ self.sleep(self.poll_interval)
507
+ # 时间已到,直接返回最后已知状态,不再发起查询。
508
+ if snapshot is not None:
509
+ view = self._view_of(kind, task_id, snapshot)
510
+ else:
511
+ view = {"task_id": task_id, "kind": kind, "status": "unknown"}
512
+ view["done"] = False
513
+ view["timed_out"] = True
514
+ view["next"] = self.settings.command(f"task wait {view.get('next_task_id') or view['task_id']}")
515
+ return view
516
+
517
+ def _view_of(self, kind: str, requested_id: str, snapshot: Any) -> dict[str, Any]:
518
+ if kind == KIND_TASK:
519
+ result, ended = snapshot
520
+ return self._plan_view(requested_id, result, ended)
521
+ if kind == KIND_CONVERSION:
522
+ return self._conversion_view(snapshot)
523
+ return self._translation_view(snapshot)
524
+
525
+ # ---- 转换 ----
526
+
527
+ def _conversion_view(self, detail: dict[str, Any]) -> dict[str, Any]:
528
+ status = _detail_str(detail, "status")
529
+ job_id = _detail_str(detail, "job_id")
530
+ view: dict[str, Any] = {
531
+ "task_id": job_id,
532
+ "kind": KIND_CONVERSION,
533
+ "status": status,
534
+ "done": status in CONVERT_DONE,
535
+ "source_file_id": _detail_str(detail, "source_file_id"),
536
+ "source_type": _detail_str(detail, "source_type"),
537
+ "target_type": _detail_str(detail, "target_type"),
538
+ }
539
+ result_id = _detail_str(detail, "result_file_id")
540
+ if status == CONVERT_COMPLETED and result_id:
541
+ item: dict[str, Any] = {"file_id": result_id}
542
+ if isinstance(detail.get("result_file_version"), int):
543
+ item["version"] = detail["result_file_version"]
544
+ url = _detail_str(detail, "attachment_url") or _detail_str(detail, "download_url")
545
+ if url:
546
+ self._known_urls[result_id] = url
547
+ view["files"] = [item]
548
+ if status == "failed":
549
+ view["failure_reason"] = _detail_str(detail, "error_message") or "conversion failed"
550
+ view["next"] = "" if view["done"] else self.settings.command(f"task wait {job_id}")
551
+ return view
552
+
553
+ def start_conversion(self, file_id: str, target: str) -> dict[str, Any]:
554
+ file_id = validate_id("file id", file_id)
555
+ target = (target or "").strip().lower().lstrip(".")
556
+ if not target:
557
+ raise UsageError("--to must name a target format, e.g. pdf")
558
+ info = self.client.file_get(file_id) or {}
559
+ name = _detail_str(info, "name")
560
+ source = _convert_source_type(name) or _convert_source_type(_detail_str(info, "file_path"))
561
+ try:
562
+ supported = (self.client.convert_supported() or {}).get("conversions") or []
563
+ except (ApiError, httpx.TransportError):
564
+ supported = None
565
+ if supported is not None and not any(
566
+ str(c.get("source_type", "")).lower() == source and str(c.get("target_type", "")).lower() == target
567
+ for c in supported
568
+ if isinstance(c, dict)
569
+ ):
570
+ raise _unsupported_conversion(supported, name or file_id, source, target)
571
+ try:
572
+ data = self.client.convert_create(file_id=file_id, source_type=source, target_type=target) or {}
573
+ except ApiError as e:
574
+ if e.code == CODE_CONVERT_UNSUPPORTED and supported is not None:
575
+ raise _unsupported_conversion(supported, name or file_id, source, target) from e
576
+ raise
577
+ job_id = _detail_str(data, "job_id")
578
+ status = _detail_str(data, "status")
579
+ return {
580
+ "task_id": job_id,
581
+ "kind": KIND_CONVERSION,
582
+ "status": status,
583
+ "done": status in CONVERT_DONE,
584
+ "reused": bool(data.get("reused")),
585
+ "source_file_id": file_id,
586
+ "source_type": source,
587
+ "target_type": target,
588
+ "next": self.settings.command(f"task wait {job_id}"),
589
+ }
590
+
591
+ # ---- 翻译 ----
592
+
593
+ def _translation_view(self, detail: dict[str, Any]) -> dict[str, Any]:
594
+ status = _detail_str(detail, "status")
595
+ job_id = _detail_str(detail, "job_id")
596
+ view: dict[str, Any] = {
597
+ "task_id": job_id,
598
+ "kind": KIND_TRANSLATION,
599
+ "status": status,
600
+ "done": status in TRANSLATION_DONE,
601
+ "source_file_id": _detail_str(detail, "source_file_id"),
602
+ "target_language": _detail_str(detail, "target_language"),
603
+ }
604
+ if status == TRANSLATION_SUCCEEDED:
605
+ files = []
606
+ for raw in detail.get("result_files") or []:
607
+ if not isinstance(raw, dict) or raw.get("is_temp") or not raw.get("file_id"):
608
+ continue
609
+ if raw.get("role") in TRANSLATION_SKIP_ROLES:
610
+ continue
611
+ item = {"file_id": raw["file_id"], "name": posixpath.basename(str(raw.get("path") or ""))}
612
+ if isinstance(raw.get("version"), int) and raw["version"]:
613
+ item["version"] = raw["version"]
614
+ files.append(item)
615
+ view["files"] = files
616
+ elif status in TRANSLATION_DONE:
617
+ failure = detail.get("failure") if isinstance(detail.get("failure"), dict) else {}
618
+ view["failure_reason"] = str(failure.get("message") or status.lower())
619
+ view["next"] = "" if view["done"] else self.settings.command(f"task wait {job_id}")
620
+ return view
621
+
622
+ def start_translation(self, file_id: str, target: str, source: str | None) -> dict[str, Any]:
623
+ file_id = validate_id("file id", file_id)
624
+ target = (target or "").strip()
625
+ if not target:
626
+ raise UsageError("--to must name a language, e.g. en")
627
+ data = self.client.translation_create(
628
+ file_id=file_id, target_language=target, source_language=(source or "").strip() or None
629
+ )
630
+ data = data or {}
631
+ job_id = _detail_str(data, "job_id")
632
+ status = _detail_str(data, "status")
633
+ return {
634
+ "task_id": job_id,
635
+ "kind": KIND_TRANSLATION,
636
+ "status": status,
637
+ "done": status in TRANSLATION_DONE,
638
+ "reused": bool(data.get("reused")),
639
+ "source_file_id": file_id,
640
+ "target_language": target,
641
+ "next": self.settings.command(f"task wait {job_id}"),
642
+ }
643
+
644
+ # ---- 回答 / 取消 ----
645
+
646
+ def reply(self, task_id: str, answers: list[str], skip: bool) -> dict[str, Any]:
647
+ if task_kind(task_id) != KIND_TASK:
648
+ raise UsageError("only tasks (plan_... ids) ask questions")
649
+ if skip and answers:
650
+ raise UsageError("use either --answer or --skip, not both")
651
+ if not skip and not answers:
652
+ raise UsageError("give at least one --answer, or --skip to let Cheso decide")
653
+ result, _, _ = self._follow(task_id, MAX_CHAIN_HOPS)
654
+ plan_id = _detail_str(result, "plan_id") or task_id
655
+ status = _detail_str(result, "status")
656
+ if status != PLAN_INTERACT:
657
+ raise CliError(
658
+ f"Task {plan_id} is not waiting for an answer (status {status}). "
659
+ f"Use `{self.settings.command(f'task wait {plan_id}')}`.",
660
+ kind="not_waiting",
661
+ extra={"task_id": plan_id, "status": status},
662
+ )
663
+ question = question_from(result.get("interaction"))
664
+ args: dict[str, Any] = {}
665
+ if question and question.get("tool_call_id"):
666
+ args["tool_call_id"] = question["tool_call_id"]
667
+ parsed: dict[str, str] = {}
668
+ if not skip:
669
+ parsed = parse_answers(answers, question)
670
+ args["answers"] = parsed
671
+ self.client.plan_interact(plan_id, args=args, skip=skip)
672
+ view: dict[str, Any] = {"task_id": plan_id, "kind": KIND_TASK, "status": "in_progress", "skipped": skip}
673
+ if parsed:
674
+ view["answers"] = parsed
675
+ view["next"] = self.settings.command(f"task wait {plan_id}")
676
+ return view
677
+
678
+ def cancel(self, task_id: str) -> dict[str, Any]:
679
+ if task_kind(task_id) != KIND_TASK:
680
+ raise UsageError(
681
+ "only tasks (plan_... ids) can be cancelled; conversions and translations finish on their own"
682
+ )
683
+ result, _, _ = self._follow(task_id, MAX_CHAIN_HOPS)
684
+ plan_id = _detail_str(result, "plan_id") or task_id
685
+ status = _detail_str(result, "status")
686
+ if status in PLAN_TERMINAL and not _detail_str(result, "next_plan_id"):
687
+ return {"task_id": plan_id, "kind": KIND_TASK, "status": status, "cancelled": False}
688
+ self.client.plan_terminate(plan_id)
689
+ return {"task_id": plan_id, "kind": KIND_TASK, "status": "terminated", "cancelled": True}
690
+
691
+ # ---- 文件 ----
692
+
693
+ def list_files(self, project_id: str) -> dict[str, Any]:
694
+ project_id = validate_id("project id", project_id)
695
+ data = self.client.project_file_tree(project_id) or {}
696
+ files: list[dict[str, Any]] = []
697
+ _collect_tree(files, "", data.get("tree") or [], 0)
698
+ return {
699
+ "project_id": project_id,
700
+ "project_url": self.settings.project_url(project_id),
701
+ "files": files,
702
+ "total": len(files),
703
+ }
704
+
705
+ def download_file(self, file_id: str, output_dir: Path, *, version: int | None = None) -> dict[str, Any]:
706
+ file_id = validate_id("file id", file_id)
707
+ info = self.client.file_get(file_id) or {}
708
+ item: dict[str, Any] = {"file_id": file_id, "name": _detail_str(info, "name") or file_id}
709
+ if version:
710
+ item["version"] = version
711
+ self._download_item(item, output_dir, strict=True)
712
+ return item
713
+
714
+ def download_results(self, view: dict[str, Any], output_dir: Path, *, deadline: Deadline | None = None) -> None:
715
+ """下载结果文件,下载失败的文件保留 ``error``。
716
+ 任务本身成功时,视图额外包含 ``download_failed`` 错误(退出码 8),并用 ``next`` 只补下载失败文件,
717
+ 避免调用方为此重新发起任务。任务本身失败时保留其原始结果,下载错误仅记录在各文件上。
718
+ """
719
+ files = view.get("files") or []
720
+ for item in files:
721
+ if view["kind"] == KIND_CONVERSION and not item.get("name"):
722
+ try:
723
+ info = self.client.file_get(item["file_id"]) or {}
724
+ item["name"] = _detail_str(info, "name")
725
+ except (ApiError, httpx.TransportError):
726
+ pass
727
+ item["name"] = item.get("name") or f"converted.{view.get('target_type') or 'out'}"
728
+ self._download_item(item, output_dir, strict=False, deadline=deadline)
729
+ failed = [item for item in files if item.get("error")]
730
+ if failed and succeeded(view):
731
+ view["error"] = {
732
+ "kind": "download_failed",
733
+ "message": f"The {view['kind']} finished, but {len(failed)} of {len(files)} result file(s) were not "
734
+ "downloaded. Download them with the `next` command; do not start the task again.",
735
+ "exit_code": int(ExitCode.DOWNLOAD_FAILED),
736
+ "file_ids": [item["file_id"] for item in failed],
737
+ }
738
+ view["next"] = " && ".join(self._download_command(item, output_dir) for item in failed)
739
+
740
+ def _download_command(self, item: dict[str, Any], output_dir: Path) -> str:
741
+ version = f" --version {item['version']}" if item.get("version") else ""
742
+ directory = shlex.quote(str(output_dir.absolute()))
743
+ return self.settings.command(f"file download {item['file_id']}{version} --output-dir {directory}")
744
+
745
+ def _download_item(
746
+ self, item: dict[str, Any], output_dir: Path, *, strict: bool, deadline: Deadline | None = None
747
+ ) -> None:
748
+ try:
749
+ url = self._known_urls.pop(item["file_id"], "")
750
+ if not url:
751
+ link = self.client.file_download_url(item["file_id"], version=item.get("version")) or {}
752
+ url = _detail_str(link, "attachment_url") or _detail_str(link, "download_url")
753
+ if not url:
754
+ raise CliError(f"no download link for {item['file_id']}")
755
+ saved = download_url_to_dir(
756
+ self.storage, url, output_dir, item.get("name") or item["file_id"], deadline=deadline
757
+ )
758
+ item["saved_to"] = str(saved)
759
+ if not strict:
760
+ self.say(f"Saved {saved}")
761
+ except (ApiError, CliError, httpx.TransportError) as e:
762
+ if strict:
763
+ raise
764
+ item["error"] = getattr(e, "message", None) or str(e)
765
+ self.say(f"warning: could not download {item.get('name') or item['file_id']}: {item['error']}")
766
+
767
+
768
+ def _collect_tree(out: list[dict[str, Any]], parent: str, nodes: list[Any], depth: int) -> None:
769
+ if depth > 32:
770
+ return
771
+ for node in nodes:
772
+ if not isinstance(node, dict):
773
+ continue
774
+ name = str(node.get("name") or "")
775
+ path = f"{parent}/{name}" if parent else name
776
+ detail = node.get("file") if isinstance(node.get("file"), dict) else None
777
+ if node.get("file_id") and (node.get("type") == "file" or detail):
778
+ item: dict[str, Any] = {"path": path, "file_id": node["file_id"]}
779
+ if node.get("kind"):
780
+ item["kind"] = node["kind"]
781
+ if detail:
782
+ if detail.get("file_path"):
783
+ item["path"] = detail["file_path"]
784
+ for key in ("file_type", "category", "mime_type", "size", "updated_at"):
785
+ if detail.get(key) not in (None, ""):
786
+ item[key] = detail[key]
787
+ out.append(item)
788
+ _collect_tree(out, path, node.get("children") or [], depth + 1)
789
+
790
+
791
+ def _convert_source_type(name: str) -> str:
792
+ ext = posixpath.splitext(name or "")[1].lower().lstrip(".")
793
+ return CONVERT_SOURCE_BY_EXT.get(ext, ext)
794
+
795
+
796
+ def _unsupported_conversion(supported: list[Any], name: str, source: str, target: str) -> CliError:
797
+ pairs = [c for c in supported if isinstance(c, dict)]
798
+ targets = sorted({str(c.get("target_type")) for c in pairs if str(c.get("source_type", "")).lower() == source})
799
+ if targets:
800
+ message = (
801
+ f"Cheso can't convert {name} ({source}) to {target}. Supported targets for this file: {', '.join(targets)}."
802
+ )
803
+ else:
804
+ sources = sorted({str(c.get("source_type")) for c in pairs})
805
+ message = (
806
+ f"Cheso can't convert {name}: type {source or 'unknown'!r} has no conversions. "
807
+ f"Convertible types: {', '.join(sources)}."
808
+ )
809
+ return CliError(
810
+ message,
811
+ exit_code=ExitCode.USAGE,
812
+ kind="unsupported_conversion",
813
+ extra={"source_type": source, "target_type": target, "supported_targets": targets},
814
+ )
815
+
816
+
817
+ def _transient(e: Exception) -> bool:
818
+ if isinstance(e, httpx.TransportError):
819
+ return True
820
+ if isinstance(e, ApiError):
821
+ status = e.http_status or 0
822
+ return status >= 500 or status == 429
823
+ return False
824
+
825
+
826
+ def _brief(e: Exception) -> str:
827
+ if isinstance(e, ApiError):
828
+ return f"http {e.http_status}" if e.http_status else e.message
829
+ return e.__class__.__name__
830
+
831
+
832
+ def _elapsed(seconds: float) -> str:
833
+ seconds = max(int(seconds), 0)
834
+ return f"{seconds // 60:02d}:{seconds % 60:02d}"