netizen-cli 0.10.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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2247 @@
1
+ """Reply Card, Activity, and Files rendering with strict manifest decoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from collections.abc import Mapping
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+ from typing import Any, Protocol
11
+
12
+ from lark_channel import OutboundCard, new_card
13
+
14
+ from ..completion_mention import valid_completion_mention_user_id
15
+ from ..domain import (
16
+ ACTIVE_STATE_VALUES,
17
+ ActiveState,
18
+ CardControlName,
19
+ FeishuScope,
20
+ GoalOperationState,
21
+ GoalStatus,
22
+ ReplyCardActivityModule,
23
+ ReplyCardFileItem,
24
+ ReplyCardFilesModule,
25
+ ReplyCardGoalModule,
26
+ ReplyCardManifest,
27
+ ReplyCardProjection,
28
+ ReplyCardResultModule,
29
+ ScopeKind,
30
+ TurnActivityManifestEntry,
31
+ TurnCommentaryManifestEntry,
32
+ TurnFileActionIntent,
33
+ TurnFileActionName,
34
+ TurnFileManifestItem,
35
+ TurnProgressManifest,
36
+ TurnProgressManifestStep,
37
+ )
38
+ from ..turn_files import (
39
+ TURN_FILE_PAGE_SIZE,
40
+ TurnFile,
41
+ TurnFilePage,
42
+ inspect_turn_file_path,
43
+ paginate_turn_files,
44
+ )
45
+ from ..turn_activity import (
46
+ ACTIVITY_COMMENTARY_LIMIT,
47
+ ACTIVITY_DETAIL_KINDS,
48
+ ACTIVITY_OPERATION_LIMIT,
49
+ ACTIVITY_PLAN_LIMIT,
50
+ ACTIVITY_TEXT_LIMIT,
51
+ TurnActivityKind,
52
+ TurnActivityStatus,
53
+ normalize_activity_text_layout,
54
+ sanitize_activity_operation_text,
55
+ sanitize_activity_text,
56
+ )
57
+ from .callbacks import (
58
+ CardActionError,
59
+ REPLY_CARD_ACTION_VERSION,
60
+ TURN_FILE_ACTION_VERSION,
61
+ TurnFileCardLimitError,
62
+ _REPEATABLE_CARD_CONTROL_NAMES,
63
+ _binding_reference,
64
+ _button_row,
65
+ _callback_button,
66
+ _decode_binding_reference,
67
+ _decode_goal_generation,
68
+ _decode_turn_reference,
69
+ _envelope,
70
+ _md_code,
71
+ _notice,
72
+ _plain,
73
+ _plain_text,
74
+ _repeatable_callback_button,
75
+ _required_string,
76
+ _scope_from_envelope,
77
+ _turn_reference,
78
+ )
79
+
80
+
81
+ TURN_FILE_MANIFEST_LIMIT = 400
82
+ TURN_FILE_CARD_JSON_LIMIT_BYTES = 55_000
83
+ _TURN_ANSWER_ELEMENT_ID = "turnanswerv1"
84
+ _TURN_FILES_ELEMENT_ID = "turnfilesv4"
85
+ _TURN_PROGRESS_ELEMENT_ID = "turnprogressv1"
86
+ _GOAL_ELEMENT_ID = "goalmodulev1"
87
+ _GOAL_OBJECTIVE_PREVIEW_CHARS = 200
88
+ _TURN_PROGRESS_MAX_STEPS = ACTIVITY_PLAN_LIMIT
89
+ _TURN_PROGRESS_STEP_MAX_CHARS = ACTIVITY_TEXT_LIMIT
90
+
91
+
92
+ class _TurnPlanStepLike(Protocol):
93
+ step: str
94
+ status: object
95
+
96
+
97
+ class _TurnActivityEntryLike(Protocol):
98
+ kind: object
99
+ status: object
100
+ event_timestamp_ms: int | None
101
+ text: str | None
102
+ count: int
103
+
104
+
105
+ class _TurnCommentaryEntryLike(Protocol):
106
+ event_timestamp_ms: int | None
107
+ text: str | None
108
+
109
+
110
+ class _TurnActivitySnapshotLike(Protocol):
111
+ state: object
112
+ steer_count: int
113
+ plan_available: bool
114
+ plan_generated: bool
115
+ plan_may_be_stale: bool
116
+ steps: tuple[_TurnPlanStepLike, ...]
117
+ commentary: tuple[_TurnCommentaryEntryLike, ...]
118
+ operations: tuple[_TurnActivityEntryLike, ...]
119
+
120
+
121
+ def reply_card(projection: ReplyCardProjection) -> OutboundCard:
122
+ """Render the closed Reply Card module set as one atomic Card 2.0 value.
123
+
124
+ The renderer is deterministic and does not read Runtime, SQLite, or the
125
+ filesystem. When Files is present every advertised page is rendered and
126
+ size-checked before the selected page is returned.
127
+ """
128
+
129
+ normalized = _normalize_reply_projection(projection)
130
+ files_module = normalized.files
131
+ if files_module is None:
132
+ return _render_reply_card_page(normalized)
133
+ turn_files = _reply_turn_files(files_module.items)
134
+ requested = paginate_turn_files(turn_files, files_module.page)
135
+ selected: OutboundCard | None = None
136
+ for page in range(requested.total_pages):
137
+ candidate = _render_reply_card_page(
138
+ replace(
139
+ normalized,
140
+ files=replace(files_module, page=page),
141
+ )
142
+ )
143
+ if page == requested.page:
144
+ selected = candidate
145
+ assert selected is not None
146
+ return selected
147
+
148
+
149
+ def reply_card_from_manifest(
150
+ *,
151
+ scope: FeishuScope,
152
+ binding_id: str,
153
+ turn_id: str,
154
+ manifest: tuple[TurnFileManifestItem, ...],
155
+ reply: ReplyCardManifest,
156
+ page: int,
157
+ additions: int | None = None,
158
+ deletions: int | None = None,
159
+ ) -> OutboundCard:
160
+ """Rebuild a v5 Reply Card from one strict self-contained page callback."""
161
+
162
+ if not manifest:
163
+ raise CardActionError("本轮文件清单为空,请重新执行任务。")
164
+ if len(manifest) > TURN_FILE_MANIFEST_LIMIT:
165
+ raise TurnFileCardLimitError(
166
+ f"本轮文件共 {len(manifest)} 个,超过卡片完整分页上限 "
167
+ f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
168
+ )
169
+ inspected = tuple(_inspect_manifest_file(entry) for entry in manifest)
170
+ return reply_card(
171
+ ReplyCardProjection(
172
+ scope=scope,
173
+ goal=reply.goal,
174
+ activity=reply.activity,
175
+ result=reply.result,
176
+ files=ReplyCardFilesModule(
177
+ binding_id=binding_id,
178
+ turn_id=turn_id,
179
+ items=tuple(_reply_file_item(item) for item in inspected),
180
+ page=page,
181
+ action_version=REPLY_CARD_ACTION_VERSION,
182
+ additions=additions,
183
+ deletions=deletions,
184
+ ),
185
+ )
186
+ )
187
+
188
+
189
+ def _normalize_reply_projection(
190
+ projection: ReplyCardProjection,
191
+ ) -> ReplyCardProjection:
192
+ if not any(
193
+ (projection.goal, projection.activity, projection.result, projection.files)
194
+ ):
195
+ raise ValueError("a Reply Card requires at least one module")
196
+ goal = _normalize_goal_module(projection.goal)
197
+ activity = _normalize_activity_module(projection.activity)
198
+ result = projection.result
199
+ if result is not None:
200
+ _bounded_card_text(result.content, "result", 100_000)
201
+ if (
202
+ result.completion_mention_user_id is not None
203
+ and valid_completion_mention_user_id(
204
+ result.completion_mention_user_id
205
+ ) is None
206
+ ):
207
+ raise ValueError("a completion mention requires one valid user open_id")
208
+ files = projection.files
209
+ if files is not None:
210
+ if projection.scope is None:
211
+ raise ValueError("a Files module requires scope")
212
+ if result is None:
213
+ raise ValueError("a Files module requires a Result module")
214
+ if not files.binding_id or not files.turn_id:
215
+ raise ValueError("a Files module requires binding_id and turn_id")
216
+ if not files.items:
217
+ raise CardActionError("本轮文件当前已不可用。")
218
+ if len(files.items) > TURN_FILE_MANIFEST_LIMIT:
219
+ raise TurnFileCardLimitError(
220
+ f"本轮文件共 {len(files.items)} 个,超过卡片完整分页上限 "
221
+ f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
222
+ )
223
+ if files.action_version not in {
224
+ TURN_FILE_ACTION_VERSION,
225
+ REPLY_CARD_ACTION_VERSION,
226
+ }:
227
+ raise ValueError("unsupported Reply Card file action version")
228
+ if (
229
+ files.action_version == TURN_FILE_ACTION_VERSION
230
+ and goal is not None
231
+ ):
232
+ raise ValueError("a Goal + Files Reply Card requires v5 callbacks")
233
+ if goal is not None and goal.binding_id != files.binding_id:
234
+ raise ValueError("Goal and Files modules require the same binding_id")
235
+ _optional_line_counts(
236
+ files.additions,
237
+ files.deletions,
238
+ field="files",
239
+ )
240
+ normalized_files = _reply_turn_files(files.items)
241
+ files = replace(
242
+ files,
243
+ items=tuple(_reply_file_item(item) for item in normalized_files),
244
+ )
245
+ if goal is not None and projection.scope is None:
246
+ raise ValueError("a Goal module requires scope")
247
+ if activity is not None:
248
+ if activity.terminal_status is None:
249
+ if activity.collapsed:
250
+ raise ValueError("a running progress card must remain expanded")
251
+ if result is not None or files is not None:
252
+ raise ValueError(
253
+ "a running Activity module cannot contain Result or Files"
254
+ )
255
+ elif files is not None and activity.terminal_status != "completed":
256
+ raise ValueError("only completed Activity may contain Files")
257
+ return replace(
258
+ projection,
259
+ goal=goal,
260
+ activity=activity,
261
+ files=files,
262
+ )
263
+
264
+
265
+ def _normalize_goal_module(
266
+ goal: ReplyCardGoalModule | None,
267
+ ) -> ReplyCardGoalModule | None:
268
+ if goal is None:
269
+ return None
270
+ _bounded_card_text(goal.binding_id, "goal.binding_id", 128)
271
+ _bounded_card_text(goal.short_id, "goal.short_id", 32)
272
+ _bounded_card_text(goal.project_alias, "goal.project_alias", 128)
273
+ if goal.status is None:
274
+ if any(
275
+ value is not None
276
+ for value in (
277
+ goal.goal_generation,
278
+ goal.runtime_state,
279
+ goal.objective,
280
+ goal.token_budget,
281
+ )
282
+ ) or goal.tokens_used != 0:
283
+ raise ValueError("an empty Goal module cannot carry Goal state")
284
+ else:
285
+ if goal.status not in {item.value for item in GoalStatus}:
286
+ raise ValueError("unsupported Goal status")
287
+ _decode_goal_generation(goal.goal_generation)
288
+ assert goal.objective is not None
289
+ _bounded_card_text(goal.objective, "goal.objective", 10_000)
290
+ if goal.runtime_state is not None:
291
+ _bounded_card_text(goal.runtime_state, "goal.runtime_state", 128)
292
+ _bounded_nonnegative_int(goal.tokens_used, "goal.tokens_used")
293
+ if goal.token_budget is not None:
294
+ _bounded_nonnegative_int(goal.token_budget, "goal.token_budget")
295
+ if goal.notice is not None:
296
+ _bounded_card_text(goal.notice, "goal.notice", 4_000)
297
+ if type(goal.notice_is_error) is not bool:
298
+ raise ValueError("goal.notice_is_error must be a boolean")
299
+ return goal
300
+
301
+
302
+ def _normalize_activity_module(
303
+ activity: ReplyCardActivityModule | None,
304
+ ) -> ReplyCardActivityModule | None:
305
+ if activity is None:
306
+ return None
307
+ terminal_status = _terminal_progress_status(activity.terminal_status)
308
+ progress = _sanitize_turn_progress_manifest(activity.progress)
309
+ _bounded_nonnegative_int(activity.hidden_steps, "activity.hidden_steps")
310
+ return replace(
311
+ activity,
312
+ progress=progress,
313
+ terminal_status=terminal_status,
314
+ collapsed=activity.collapsed or terminal_status is not None,
315
+ )
316
+
317
+
318
+ def _sanitize_turn_progress_manifest(
319
+ progress: TurnProgressManifest,
320
+ ) -> TurnProgressManifest:
321
+ return _decode_turn_progress_manifest(_encode_turn_progress_manifest(progress))
322
+
323
+
324
+ def _activity_timestamp_ms(value: object) -> int | None:
325
+ if value is None:
326
+ return None
327
+ if (
328
+ isinstance(value, bool)
329
+ or not isinstance(value, int)
330
+ or not 0 <= value <= 10**18
331
+ ):
332
+ raise ValueError("activity timestamp is invalid")
333
+ return value
334
+
335
+
336
+ def _decode_activity_timestamp_ms(value: object, field: str) -> int | None:
337
+ try:
338
+ return _activity_timestamp_ms(value)
339
+ except ValueError as error:
340
+ raise CardActionError(f"{field} 无效。") from error
341
+
342
+
343
+ def _activity_operation_text(kind: object, value: object) -> str | None:
344
+ if value is None:
345
+ return None
346
+ if not isinstance(value, str) or not value:
347
+ raise ValueError("activity operation text is invalid")
348
+ if kind in ACTIVITY_DETAIL_KINDS:
349
+ # Preserve frozen summaries from older 160-character manifests on
350
+ # pagination, including exit-code suffixes. New native previews use 120.
351
+ if len(value) > ACTIVITY_TEXT_LIMIT:
352
+ raise ValueError("activity operation text must be bounded")
353
+ return sanitize_activity_operation_text(value, limit=ACTIVITY_TEXT_LIMIT)
354
+ if kind == TurnActivityKind.TOOL.value:
355
+ return value
356
+ raise ValueError("activity text is unsupported for this operation")
357
+
358
+
359
+ def _bounded_card_text(value: Any, field: str, limit: int) -> str:
360
+ text = _required_string(value, field)
361
+ if len(text) > limit or "\x00" in text:
362
+ raise ValueError(f"{field} is invalid")
363
+ return text
364
+
365
+
366
+ def _bounded_nonnegative_int(value: Any, field: str) -> int:
367
+ if (
368
+ isinstance(value, bool)
369
+ or not isinstance(value, int)
370
+ or not 0 <= value <= 10**18
371
+ ):
372
+ raise ValueError(f"{field} must be a bounded non-negative integer")
373
+ return value
374
+
375
+
376
+ def _optional_line_counts(
377
+ additions: Any,
378
+ deletions: Any,
379
+ *,
380
+ field: str,
381
+ ) -> tuple[int | None, int | None]:
382
+ if additions is None and deletions is None:
383
+ return None, None
384
+ if additions is None or deletions is None:
385
+ raise ValueError(f"{field} line counts must be present together")
386
+ return (
387
+ _bounded_nonnegative_int(additions, f"{field}.additions"),
388
+ _bounded_nonnegative_int(deletions, f"{field}.deletions"),
389
+ )
390
+
391
+
392
+ def _inspect_manifest_file(entry: TurnFileManifestItem) -> TurnFile:
393
+ if entry.deleted:
394
+ return TurnFile(
395
+ entry.label, Path(entry.path), None, None,
396
+ entry.additions, entry.deletions, deleted=True,
397
+ )
398
+ inspected = inspect_turn_file_path(entry.path, entry.label)
399
+ if not inspected.available or inspected.media_kind == "image":
400
+ return inspected
401
+ return replace(
402
+ inspected,
403
+ additions=entry.additions,
404
+ deletions=entry.deletions,
405
+ )
406
+
407
+
408
+ def _reply_file_item(turn_file: TurnFile) -> ReplyCardFileItem:
409
+ return ReplyCardFileItem(
410
+ path=str(turn_file.resolved_path),
411
+ label=turn_file.display_path,
412
+ size=turn_file.size,
413
+ media_kind=turn_file.media_kind,
414
+ additions=turn_file.additions,
415
+ deletions=turn_file.deletions,
416
+ deleted=turn_file.deleted,
417
+ )
418
+
419
+
420
+ def _reply_turn_files(
421
+ items: tuple[ReplyCardFileItem, ...],
422
+ ) -> tuple[TurnFile, ...]:
423
+ results: list[TurnFile] = []
424
+ seen: set[str] = set()
425
+ for item in items:
426
+ path = _decode_turn_file_path(item.path)
427
+ label = _bounded_card_text(item.label, "file.label", 1024)
428
+ if path in seen:
429
+ raise CardActionError("本轮文件清单包含重复路径。")
430
+ seen.add(path)
431
+ size = item.size
432
+ media_kind = item.media_kind
433
+ if type(item.deleted) is not bool or (item.deleted and size is not None):
434
+ raise ValueError("deleted files must be unavailable")
435
+ if (size is None) != (media_kind is None):
436
+ raise ValueError("file availability fields must change together")
437
+ if size is not None:
438
+ _bounded_nonnegative_int(size, "file.size")
439
+ if media_kind not in {"image", "file"}:
440
+ raise ValueError("file.media_kind is invalid")
441
+ additions, deletions = _optional_line_counts(
442
+ item.additions,
443
+ item.deletions,
444
+ field="file",
445
+ )
446
+ if media_kind != "file" and not item.deleted:
447
+ additions = None
448
+ deletions = None
449
+ results.append(
450
+ TurnFile(
451
+ display_path=label,
452
+ resolved_path=Path(path),
453
+ size=size,
454
+ media_kind=media_kind,
455
+ additions=additions,
456
+ deletions=deletions,
457
+ deleted=item.deleted,
458
+ )
459
+ )
460
+ return tuple(results)
461
+
462
+
463
+ def turn_files_card(
464
+ *,
465
+ scope: FeishuScope,
466
+ binding_id: str,
467
+ turn_id: str,
468
+ final_response: str,
469
+ files: tuple[TurnFile, ...],
470
+ page: int = 0,
471
+ additions: int | None = None,
472
+ deletions: int | None = None,
473
+ completion_mention_user_id: str | None = None,
474
+ ) -> OutboundCard:
475
+ return reply_card(
476
+ ReplyCardProjection(
477
+ scope=scope,
478
+ result=ReplyCardResultModule(
479
+ final_response,
480
+ completion_mention_user_id=completion_mention_user_id,
481
+ ),
482
+ files=ReplyCardFilesModule(
483
+ binding_id=binding_id,
484
+ turn_id=turn_id,
485
+ items=tuple(_reply_file_item(item) for item in files),
486
+ page=page,
487
+ action_version=TURN_FILE_ACTION_VERSION,
488
+ additions=additions,
489
+ deletions=deletions,
490
+ ),
491
+ )
492
+ )
493
+
494
+
495
+ def turn_progress_card(
496
+ *,
497
+ snapshot: _TurnActivitySnapshotLike,
498
+ final_response: str | None = None,
499
+ files: tuple[TurnFile, ...] = (),
500
+ terminal_status: str | None = None,
501
+ collapsed: bool = False,
502
+ scope: FeishuScope | None = None,
503
+ binding_id: str | None = None,
504
+ turn_id: str | None = None,
505
+ additions: int | None = None,
506
+ deletions: int | None = None,
507
+ completion_mention_user_id: str | None = None,
508
+ ) -> OutboundCard:
509
+ """Render one replaceable Phase 1 Turn progress card.
510
+
511
+ The activity panel reads only the bounded, safety-projected status,
512
+ commentary, generic operations, and checklist. It never inspects
513
+ reasoning, tool arguments, or tool output.
514
+ A terminal render always collapses that panel and appends the authoritative
515
+ final response plus the existing optional Turn-file controls.
516
+ """
517
+
518
+ normalized_terminal_status = _terminal_progress_status(terminal_status)
519
+ if normalized_terminal_status is None:
520
+ if completion_mention_user_id is not None:
521
+ raise ValueError("a running progress card cannot contain a completion mention")
522
+ if collapsed:
523
+ raise ValueError("a running progress card must remain expanded")
524
+ if final_response is not None or files:
525
+ raise ValueError(
526
+ "a running progress card cannot contain a final response or files"
527
+ )
528
+ elif files and normalized_terminal_status != "completed":
529
+ raise ValueError("only a completed progress card may contain files")
530
+
531
+ activity = ReplyCardActivityModule(
532
+ progress=_turn_progress_manifest(snapshot),
533
+ terminal_status=normalized_terminal_status,
534
+ collapsed=collapsed,
535
+ # v4 file callbacks freeze only the already-bounded progress manifest.
536
+ # Preserve their established initial/paged behavior until that legacy
537
+ # schema ages out; v5 Goal cards carry hidden_steps explicitly.
538
+ hidden_steps=(
539
+ 0
540
+ if files
541
+ else max(0, len(snapshot.steps) - _TURN_PROGRESS_MAX_STEPS)
542
+ ),
543
+ )
544
+ files_module = None
545
+ if files:
546
+ if scope is None or not binding_id or not turn_id:
547
+ raise ValueError(
548
+ "a progress card with files requires scope, binding_id, and turn_id"
549
+ )
550
+ files_module = ReplyCardFilesModule(
551
+ binding_id=binding_id,
552
+ turn_id=turn_id,
553
+ items=tuple(_reply_file_item(item) for item in files),
554
+ action_version=TURN_FILE_ACTION_VERSION,
555
+ additions=additions,
556
+ deletions=deletions,
557
+ )
558
+ result = None
559
+ if normalized_terminal_status is not None:
560
+ result = ReplyCardResultModule(
561
+ final_response or _default_terminal_response(normalized_terminal_status),
562
+ completion_mention_user_id=completion_mention_user_id,
563
+ )
564
+ return reply_card(
565
+ ReplyCardProjection(
566
+ scope=scope,
567
+ activity=activity,
568
+ result=result,
569
+ files=files_module,
570
+ )
571
+ )
572
+
573
+
574
+ def _turn_file_manifest(
575
+ files: tuple[TurnFile, ...],
576
+ ) -> tuple[TurnFileManifestItem, ...]:
577
+ return tuple(
578
+ TurnFileManifestItem(
579
+ path=str(turn_file.resolved_path),
580
+ label=turn_file.display_path,
581
+ additions=turn_file.additions,
582
+ deletions=turn_file.deletions,
583
+ deleted=turn_file.deleted,
584
+ )
585
+ for turn_file in files
586
+ )
587
+
588
+
589
+ def _turn_progress_manifest(
590
+ snapshot: _TurnActivitySnapshotLike,
591
+ ) -> TurnProgressManifest:
592
+ state = getattr(snapshot.state, "value", snapshot.state)
593
+ if state not in ACTIVE_STATE_VALUES:
594
+ state = ActiveState.RUNNING.value
595
+ steps = tuple(
596
+ TurnProgressManifestStep(
597
+ step=activity_step_display(item.step),
598
+ status=(
599
+ getattr(item.status, "value", item.status)
600
+ if getattr(item.status, "value", item.status)
601
+ in {"pending", "inProgress", "completed"}
602
+ else "pending"
603
+ ),
604
+ )
605
+ for item in snapshot.steps[:_TURN_PROGRESS_MAX_STEPS]
606
+ )
607
+ snapshot_commentary = tuple(getattr(snapshot, "commentary", ()))
608
+ snapshot_operations = tuple(getattr(snapshot, "operations", ()))
609
+ commentary = tuple(
610
+ TurnCommentaryManifestEntry(
611
+ text=sanitized,
612
+ event_timestamp_ms=_activity_timestamp_ms(item.event_timestamp_ms),
613
+ )
614
+ for item in snapshot_commentary[-ACTIVITY_COMMENTARY_LIMIT:]
615
+ if item.text is not None
616
+ and (sanitized := sanitize_activity_text(item.text)) is not None
617
+ )
618
+ operations = tuple(
619
+ TurnActivityManifestEntry(
620
+ kind=(
621
+ getattr(item.kind, "value", item.kind)
622
+ if getattr(item.kind, "value", item.kind)
623
+ in {kind.value for kind in TurnActivityKind if kind is not TurnActivityKind.COMMENTARY}
624
+ else TurnActivityKind.TOOL.value
625
+ ),
626
+ status=(
627
+ getattr(item.status, "value", item.status)
628
+ if getattr(item.status, "value", item.status)
629
+ in {status.value for status in TurnActivityStatus}
630
+ else TurnActivityStatus.IN_PROGRESS.value
631
+ ),
632
+ event_timestamp_ms=_activity_timestamp_ms(item.event_timestamp_ms),
633
+ text=_activity_operation_text(
634
+ getattr(item.kind, "value", item.kind),
635
+ item.text,
636
+ ),
637
+ count=max(0, item.count),
638
+ )
639
+ for item in snapshot_operations[-ACTIVITY_OPERATION_LIMIT:]
640
+ )
641
+ return TurnProgressManifest(
642
+ state=state,
643
+ steer_count=max(0, snapshot.steer_count),
644
+ plan_available=bool(snapshot.plan_available),
645
+ plan_generated=bool(snapshot.plan_generated),
646
+ plan_may_be_stale=bool(snapshot.plan_may_be_stale),
647
+ steps=steps,
648
+ commentary=commentary,
649
+ operations=operations,
650
+ )
651
+
652
+
653
+ def _render_reply_card_page(projection: ReplyCardProjection) -> OutboundCard:
654
+ """Pure single-page renderer; callers validate every page atomically."""
655
+
656
+ title, subtitle, template, summary = _reply_card_chrome(projection)
657
+ builder = (
658
+ new_card()
659
+ .config(
660
+ update_multi=True,
661
+ width_mode="default",
662
+ summary={"content": summary},
663
+ )
664
+ .header(
665
+ title,
666
+ subtitle=subtitle,
667
+ template=template,
668
+ icon={"tag": "standard_icon", "token": "todo_colorful"},
669
+ )
670
+ )
671
+ if projection.goal is not None:
672
+ builder.raw(
673
+ _reply_goal_block(
674
+ scope=projection.scope,
675
+ goal=projection.goal,
676
+ )
677
+ )
678
+ if projection.activity is not None:
679
+ activity = projection.activity
680
+ builder.raw(
681
+ _turn_progress_panel(
682
+ activity.progress,
683
+ terminal_status=activity.terminal_status,
684
+ expanded=not activity.collapsed,
685
+ hidden_steps=activity.hidden_steps,
686
+ goal=projection.goal is not None,
687
+ )
688
+ )
689
+ if projection.result is not None:
690
+ builder.raw(_turn_answer_block(projection.result))
691
+ if projection.files is not None:
692
+ assert projection.scope is not None
693
+ files = projection.files
694
+ turn_files = _reply_turn_files(files.items)
695
+ visible = paginate_turn_files(turn_files, files.page)
696
+ for block in _turn_files_blocks(
697
+ scope=projection.scope,
698
+ binding_id=files.binding_id,
699
+ turn_id=files.turn_id,
700
+ page=visible,
701
+ manifest=tuple(
702
+ TurnFileManifestItem(
703
+ item.path,
704
+ item.label,
705
+ item.additions,
706
+ item.deletions,
707
+ deleted=item.deleted,
708
+ )
709
+ for item in files.items
710
+ ),
711
+ final_response=(
712
+ projection.result.content
713
+ if projection.result is not None
714
+ else ""
715
+ ),
716
+ progress=(
717
+ projection.activity.progress
718
+ if projection.activity is not None
719
+ else None
720
+ ),
721
+ reply=_reply_card_manifest(projection),
722
+ action_version=files.action_version,
723
+ additions=files.additions,
724
+ deletions=files.deletions,
725
+ ):
726
+ builder.raw(block)
727
+ card = builder.to_dict()
728
+ body = card.get("body")
729
+ if isinstance(body, dict):
730
+ body.update(
731
+ {
732
+ "direction": "vertical",
733
+ "padding": "12px 12px 20px 12px",
734
+ "vertical_spacing": "12px",
735
+ }
736
+ )
737
+ _validate_turn_card_size(
738
+ card,
739
+ label="回复卡片",
740
+ untruncated="卡片内容",
741
+ )
742
+ return OutboundCard(card=card)
743
+
744
+
745
+ def _reply_card_chrome(
746
+ projection: ReplyCardProjection,
747
+ ) -> tuple[str, str | None, str, str]:
748
+ goal = projection.goal
749
+ if goal is not None:
750
+ state = goal.status
751
+ if state is None and goal.notice_is_error:
752
+ template = "red"
753
+ summary = "Goal 状态未确认"
754
+ elif state == GoalStatus.COMPLETE.value:
755
+ template = "green"
756
+ summary = "Goal 已完成"
757
+ elif state in {
758
+ GoalStatus.BLOCKED.value,
759
+ GoalStatus.USAGE_LIMITED.value,
760
+ GoalStatus.BUDGET_LIMITED.value,
761
+ }:
762
+ template = "orange"
763
+ summary = "Goal 等待处理"
764
+ elif state == GoalStatus.PAUSED.value:
765
+ template = "orange"
766
+ summary = "Goal 已暂停"
767
+ else:
768
+ template = "blue"
769
+ summary = "Goal 正在执行" if state else "Codex Goal"
770
+ return (
771
+ "Codex Goal",
772
+ f"{goal.short_id} · {goal.project_alias}",
773
+ template,
774
+ summary,
775
+ )
776
+ if projection.activity is not None:
777
+ title, template, summary = _progress_card_chrome(
778
+ projection.activity.terminal_status
779
+ )
780
+ return title, None, template, summary
781
+ if projection.files is not None:
782
+ files = projection.files
783
+ visible = paginate_turn_files(_reply_turn_files(files.items), files.page)
784
+ subtitle = (
785
+ f"本轮文件 {visible.total_items} 个 · "
786
+ f"第 {visible.page + 1}/{visible.total_pages} 页"
787
+ )
788
+ return (
789
+ "任务已完成",
790
+ subtitle,
791
+ "green",
792
+ f"任务已完成 · 本轮文件 {visible.total_items} 个",
793
+ )
794
+ return "任务已完成", None, "green", "任务已完成"
795
+
796
+
797
+ def _reply_goal_block(
798
+ *,
799
+ scope: FeishuScope | None,
800
+ goal: ReplyCardGoalModule,
801
+ ) -> dict[str, Any]:
802
+ elements: list[dict[str, Any]] = []
803
+ if goal.notice:
804
+ elements.append(_notice(goal.notice, error=goal.notice_is_error))
805
+ if goal.status is None:
806
+ if goal.notice_is_error:
807
+ elements.append(
808
+ {
809
+ "tag": "markdown",
810
+ "content": (
811
+ "无法安全确认原生 Goal 是否存在;当前会话仍保持占用,"
812
+ "请勿启动新的原生操作。"
813
+ ),
814
+ }
815
+ )
816
+ else:
817
+ elements.append(
818
+ {
819
+ "tag": "markdown",
820
+ "content": (
821
+ "当前原生 Thread 没有 Goal。使用 `/goal <objective>` 启动;"
822
+ "Goal 可跨多个物理 Turn 自动继续。"
823
+ ),
824
+ }
825
+ )
826
+ else:
827
+ state = goal.runtime_state or f"goal-{goal.status}"
828
+ budget = "未设置" if goal.token_budget is None else str(goal.token_budget)
829
+ objective = _goal_objective_preview(goal.objective or "")
830
+ elements.append(
831
+ {
832
+ "tag": "markdown",
833
+ "content": (
834
+ f"**状态**:`{_md_code(state)}`\n"
835
+ f"**Objective**:{_md_code(objective)}\n"
836
+ f"**Tokens**:{goal.tokens_used} / {budget}"
837
+ ),
838
+ }
839
+ )
840
+ assert scope is not None and goal.goal_generation is not None
841
+ buttons: list[dict[str, Any]] = []
842
+ external = goal.runtime_state == "externally-active-goal"
843
+ controls_unknown = goal.runtime_state == "goal-unknown"
844
+ pausing = goal.runtime_state == GoalOperationState.PAUSING.value
845
+ if (
846
+ goal.status == GoalStatus.ACTIVE.value
847
+ and not external
848
+ and not controls_unknown
849
+ and not pausing
850
+ ):
851
+ buttons.append(
852
+ _goal_control_button(
853
+ scope=scope,
854
+ goal=goal,
855
+ name=CardControlName.GOAL_PAUSE,
856
+ label="暂停 Goal",
857
+ style="primary",
858
+ confirm=("暂停 Goal", "将暂停 Goal 并中断当前物理 Turn。"),
859
+ )
860
+ )
861
+ if (
862
+ goal.status == GoalStatus.PAUSED.value
863
+ and not external
864
+ and not controls_unknown
865
+ ):
866
+ buttons.append(
867
+ _goal_control_button(
868
+ scope=scope,
869
+ goal=goal,
870
+ name=CardControlName.GOAL_RESUME,
871
+ label="恢复 Goal",
872
+ style="primary_filled",
873
+ )
874
+ )
875
+ if (
876
+ goal.status != GoalStatus.ACTIVE.value
877
+ and not controls_unknown
878
+ and goal.runtime_state != "goal-cleared"
879
+ ):
880
+ buttons.append(
881
+ _goal_control_button(
882
+ scope=scope,
883
+ goal=goal,
884
+ name=CardControlName.GOAL_CLEAR,
885
+ label="结束 Goal",
886
+ confirm=("结束 Goal", "结束后将无法从此 Goal 状态恢复。"),
887
+ )
888
+ )
889
+ if buttons:
890
+ elements.append(_button_row(*buttons))
891
+ elif external:
892
+ elements.append(
893
+ _notice(
894
+ "这是重启前或外部客户端启动的 active Goal;"
895
+ "当前 SDK 无法安全补收通知并重挂。请先在原生 Codex 中暂停。"
896
+ )
897
+ )
898
+ return {
899
+ "tag": "column_set",
900
+ "element_id": _GOAL_ELEMENT_ID,
901
+ "flex_mode": "none",
902
+ "background_style": "grey-50",
903
+ "columns": [
904
+ {
905
+ "tag": "column",
906
+ "width": "weighted",
907
+ "weight": 1,
908
+ "padding": "12px",
909
+ "vertical_spacing": "8px",
910
+ "elements": elements,
911
+ }
912
+ ],
913
+ }
914
+
915
+
916
+ def _goal_objective_preview(objective: str) -> str:
917
+ if len(objective) <= _GOAL_OBJECTIVE_PREVIEW_CHARS:
918
+ return objective
919
+ return f"{objective[:_GOAL_OBJECTIVE_PREVIEW_CHARS]}…"
920
+
921
+
922
+ def _goal_control_button(
923
+ *,
924
+ scope: FeishuScope,
925
+ goal: ReplyCardGoalModule,
926
+ name: CardControlName,
927
+ label: str,
928
+ style: str | None = None,
929
+ confirm: tuple[str, str] | None = None,
930
+ ) -> dict[str, Any]:
931
+ button = (
932
+ _repeatable_callback_button
933
+ if name in _REPEATABLE_CARD_CONTROL_NAMES
934
+ else _callback_button
935
+ )
936
+ return button(
937
+ label=label,
938
+ value=_envelope(
939
+ scope,
940
+ name,
941
+ binding_id=_binding_reference(goal.binding_id),
942
+ goal_generation=goal.goal_generation,
943
+ expected_goal_status=goal.status,
944
+ ),
945
+ style=style or "default",
946
+ confirm=confirm,
947
+ )
948
+
949
+
950
+ def _reply_card_manifest(projection: ReplyCardProjection) -> ReplyCardManifest:
951
+ return ReplyCardManifest(
952
+ goal=projection.goal,
953
+ activity=projection.activity,
954
+ result=(
955
+ None
956
+ if projection.result is None
957
+ else replace(projection.result, completion_mention_user_id=None)
958
+ ),
959
+ )
960
+
961
+
962
+ def _terminal_progress_status(value: str | None) -> str | None:
963
+ if value is None:
964
+ return None
965
+ normalized = getattr(value, "value", value)
966
+ if normalized not in {"completed", "interrupted", "failed"}:
967
+ raise ValueError(f"unsupported terminal progress status: {normalized!r}")
968
+ return normalized
969
+
970
+
971
+ def _progress_card_chrome(status: str | None) -> tuple[str, str, str]:
972
+ if status == "completed":
973
+ return "任务已完成", "green", "任务已完成"
974
+ if status == "interrupted":
975
+ return "任务已中断", "orange", "任务已中断"
976
+ if status == "failed":
977
+ return "任务未完成", "red", "任务未完成"
978
+ return "任务进行中", "blue", "任务正在执行,进度会逐步更新"
979
+
980
+
981
+ def _default_terminal_response(status: str) -> str:
982
+ if status == "completed":
983
+ return "任务已结束,未产生文本回复。"
984
+ if status == "interrupted":
985
+ return "Codex Turn 已中断。"
986
+ return "任务未完成。"
987
+
988
+
989
+ def _turn_progress_panel(
990
+ snapshot: _TurnActivitySnapshotLike,
991
+ *,
992
+ terminal_status: str | None,
993
+ expanded: bool,
994
+ hidden_steps: int = 0,
995
+ goal: bool = False,
996
+ ) -> dict[str, Any]:
997
+ status_label = _progress_status_label(snapshot, terminal_status)
998
+ return {
999
+ "tag": "collapsible_panel",
1000
+ "element_id": _TURN_PROGRESS_ELEMENT_ID,
1001
+ "expanded": expanded,
1002
+ "border": {"color": "grey", "corner_radius": "8px"},
1003
+ "padding": "8px 12px 12px 12px",
1004
+ "vertical_spacing": "8px",
1005
+ "header": {
1006
+ "title": {
1007
+ "tag": "plain_text",
1008
+ "content": f"执行过程 · {status_label}",
1009
+ },
1010
+ "vertical_align": "center",
1011
+ "icon": {
1012
+ "tag": "standard_icon",
1013
+ "token": "down-small-ccm_outlined",
1014
+ "size": "16px 16px",
1015
+ },
1016
+ "icon_position": "right",
1017
+ "icon_expanded_angle": -180,
1018
+ },
1019
+ "elements": _turn_activity_elements(
1020
+ snapshot,
1021
+ status_label=status_label,
1022
+ hidden_steps=hidden_steps,
1023
+ goal=goal,
1024
+ ),
1025
+ }
1026
+
1027
+
1028
+ def _progress_status_label(
1029
+ snapshot: _TurnActivitySnapshotLike,
1030
+ terminal_status: str | None,
1031
+ ) -> str:
1032
+ if terminal_status == "completed":
1033
+ return "已完成"
1034
+ if terminal_status == "interrupted":
1035
+ return "已中断"
1036
+ if terminal_status == "failed":
1037
+ return "未完成"
1038
+ state = getattr(snapshot.state, "value", snapshot.state)
1039
+ if state == ActiveState.STOPPING.value:
1040
+ return "正在停止"
1041
+ if state == ActiveState.OBSERVATION_UNAVAILABLE.value:
1042
+ return "Turn 观测不可用"
1043
+ return "正在执行"
1044
+
1045
+
1046
+ def _turn_activity_elements(
1047
+ snapshot: _TurnActivitySnapshotLike,
1048
+ *,
1049
+ status_label: str,
1050
+ hidden_steps: int = 0,
1051
+ goal: bool = False,
1052
+ ) -> list[dict[str, Any]]:
1053
+ elements = [_plain(f"状态:{status_label}")]
1054
+ if snapshot.steer_count:
1055
+ elements.append(_plain(f"已接收调整:{snapshot.steer_count} 次"))
1056
+ commentary = tuple(getattr(snapshot, "commentary", ()))[
1057
+ -ACTIVITY_COMMENTARY_LIMIT:
1058
+ ]
1059
+ if commentary:
1060
+ elements.append({"tag": "markdown", "content": "**最近进展**"})
1061
+ for item in commentary:
1062
+ if item.text is None:
1063
+ continue
1064
+ elements.append(
1065
+ _activity_markdown_row(
1066
+ item.event_timestamp_ms,
1067
+ activity_step_display(item.text),
1068
+ )
1069
+ )
1070
+ operations = tuple(getattr(snapshot, "operations", ()))[
1071
+ -ACTIVITY_OPERATION_LIMIT:
1072
+ ]
1073
+ if operations:
1074
+ elements.append({"tag": "markdown", "content": "**最近操作**"})
1075
+ for item in operations:
1076
+ elements.append(
1077
+ _activity_markdown_row(
1078
+ item.event_timestamp_ms,
1079
+ _activity_operation_display(item),
1080
+ )
1081
+ )
1082
+ if not snapshot.plan_available:
1083
+ elements.append(_plain("过程信息:暂不可用"))
1084
+ return elements
1085
+ if not snapshot.plan_generated:
1086
+ suffix = (
1087
+ "(最近一次调整后仍在等待更新)"
1088
+ if snapshot.plan_may_be_stale
1089
+ else ""
1090
+ )
1091
+ elements.append(
1092
+ {
1093
+ "tag": "markdown",
1094
+ "content": f"**任务清单**:Codex 尚未生成{suffix}",
1095
+ }
1096
+ )
1097
+ return elements
1098
+
1099
+ title = "**任务清单**"
1100
+ if goal:
1101
+ title += "(最近一次原生上报,可能尚未反映追加消息)"
1102
+ elif snapshot.plan_may_be_stale:
1103
+ title += "(可能尚未反映最近一次调整)"
1104
+ elements.append({"tag": "markdown", "content": title})
1105
+ visible = snapshot.steps[:_TURN_PROGRESS_MAX_STEPS]
1106
+ if not visible:
1107
+ elements.append(_plain("(当前为空)"))
1108
+ icons = {"completed": "✓", "inProgress": "→", "pending": "○"}
1109
+ for item in visible:
1110
+ status = getattr(item.status, "value", item.status)
1111
+ elements.append(
1112
+ _plain(
1113
+ f"{icons.get(status, '○')} "
1114
+ f"{activity_step_display(item.step)}"
1115
+ )
1116
+ )
1117
+ remaining = len(snapshot.steps) - len(visible) + hidden_steps
1118
+ if remaining > 0:
1119
+ elements.append(_plain(f"… 另有 {remaining} 项未展示"))
1120
+ return elements
1121
+
1122
+
1123
+ def _activity_operation_display(item: _TurnActivityEntryLike) -> str:
1124
+ kind = getattr(item.kind, "value", item.kind)
1125
+ status = getattr(item.status, "value", item.status)
1126
+ labels = {
1127
+ TurnActivityKind.COMMAND.value: "执行命令",
1128
+ TurnActivityKind.TOOL.value: "调用工具",
1129
+ TurnActivityKind.FILE_CHANGE.value: "修改文件",
1130
+ TurnActivityKind.WEB_SEARCH.value: "搜索网页",
1131
+ TurnActivityKind.IMAGE.value: "处理图片",
1132
+ TurnActivityKind.SUBAGENT.value: "子任务",
1133
+ TurnActivityKind.REVIEW.value: "代码审查",
1134
+ TurnActivityKind.COMPACTION.value: "压缩上下文",
1135
+ }
1136
+ icons = {
1137
+ TurnActivityStatus.IN_PROGRESS.value: "→",
1138
+ TurnActivityStatus.COMPLETED.value: "✓",
1139
+ TurnActivityStatus.FAILED.value: "×",
1140
+ TurnActivityStatus.DECLINED.value: "×",
1141
+ TurnActivityStatus.INTERRUPTED.value: "×",
1142
+ }
1143
+ label = labels.get(kind, "执行操作")
1144
+ if kind in ACTIVITY_DETAIL_KINDS and item.text:
1145
+ label = item.text
1146
+ count = item.count
1147
+ if kind in {
1148
+ TurnActivityKind.FILE_CHANGE.value,
1149
+ TurnActivityKind.SUBAGENT.value,
1150
+ } and count != 1:
1151
+ label += f"({max(0, count)} 项)"
1152
+ if kind == TurnActivityKind.TOOL.value and item.text:
1153
+ label += f":{item.text}"
1154
+ return f"{icons.get(status, '→')} {label}"
1155
+
1156
+
1157
+ def _activity_markdown_row(
1158
+ timestamp_ms: int | None,
1159
+ content: str,
1160
+ ) -> dict[str, Any]:
1161
+ prefix = ""
1162
+ if timestamp_ms is not None:
1163
+ timestamp = _activity_timestamp_ms(timestamp_ms)
1164
+ assert timestamp is not None
1165
+ prefix = (
1166
+ f"<local_datetime millisecond='{timestamp}' "
1167
+ "format_type='date_num'></local_datetime> "
1168
+ f"<local_datetime millisecond='{timestamp}' "
1169
+ "format_type='time'></local_datetime> · "
1170
+ )
1171
+ return {
1172
+ "tag": "markdown",
1173
+ "content": prefix + _escape_activity_markdown(content),
1174
+ }
1175
+
1176
+
1177
+ def _escape_activity_markdown(value: str) -> str:
1178
+ visible = normalize_activity_text_layout(value)
1179
+ escaped = html.escape(visible.replace("\\", "\\\\"), quote=False)
1180
+ for marker in ("`", "*", "_", "~", "[", "]", "(", ")"):
1181
+ escaped = escaped.replace(marker, f"\\{marker}")
1182
+ return escaped
1183
+
1184
+
1185
+ def activity_step_display(value: str) -> str:
1186
+ """Return bounded display text with conservative credential redaction."""
1187
+
1188
+ return sanitize_activity_text(value) or "未命名步骤"
1189
+
1190
+
1191
+ def turn_files_card_from_manifest(
1192
+ *,
1193
+ scope: FeishuScope,
1194
+ binding_id: str,
1195
+ turn_id: str,
1196
+ final_response: str,
1197
+ manifest: tuple[TurnFileManifestItem, ...],
1198
+ page: int,
1199
+ additions: int | None = None,
1200
+ deletions: int | None = None,
1201
+ ) -> OutboundCard:
1202
+ """Rebuild a v4 card using only state carried by its page callback."""
1203
+
1204
+ if not manifest:
1205
+ raise CardActionError("本轮文件清单为空,请重新执行任务。")
1206
+ if len(manifest) > TURN_FILE_MANIFEST_LIMIT:
1207
+ raise TurnFileCardLimitError(
1208
+ f"本轮文件共 {len(manifest)} 个,超过卡片完整分页上限 "
1209
+ f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
1210
+ )
1211
+ files = tuple(_inspect_manifest_file(entry) for entry in manifest)
1212
+ return reply_card(
1213
+ ReplyCardProjection(
1214
+ scope=scope,
1215
+ result=ReplyCardResultModule(final_response),
1216
+ files=ReplyCardFilesModule(
1217
+ binding_id=binding_id,
1218
+ turn_id=turn_id,
1219
+ items=tuple(_reply_file_item(item) for item in files),
1220
+ page=page,
1221
+ action_version=TURN_FILE_ACTION_VERSION,
1222
+ additions=additions,
1223
+ deletions=deletions,
1224
+ ),
1225
+ )
1226
+ )
1227
+
1228
+
1229
+ def turn_progress_card_from_manifest(
1230
+ *,
1231
+ scope: FeishuScope,
1232
+ binding_id: str,
1233
+ turn_id: str,
1234
+ final_response: str,
1235
+ manifest: tuple[TurnFileManifestItem, ...],
1236
+ progress: TurnProgressManifest,
1237
+ page: int,
1238
+ additions: int | None = None,
1239
+ deletions: int | None = None,
1240
+ ) -> OutboundCard:
1241
+ """Rebuild a completed progress card from its self-contained callback."""
1242
+
1243
+ if not manifest:
1244
+ raise CardActionError("本轮文件清单为空,请重新执行任务。")
1245
+ if len(manifest) > TURN_FILE_MANIFEST_LIMIT:
1246
+ raise TurnFileCardLimitError(
1247
+ f"本轮文件共 {len(manifest)} 个,超过卡片完整分页上限 "
1248
+ f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
1249
+ )
1250
+ files = tuple(_inspect_manifest_file(entry) for entry in manifest)
1251
+ return reply_card(
1252
+ ReplyCardProjection(
1253
+ scope=scope,
1254
+ activity=ReplyCardActivityModule(
1255
+ progress=progress,
1256
+ terminal_status="completed",
1257
+ collapsed=True,
1258
+ ),
1259
+ result=ReplyCardResultModule(final_response),
1260
+ files=ReplyCardFilesModule(
1261
+ binding_id=binding_id,
1262
+ turn_id=turn_id,
1263
+ items=tuple(_reply_file_item(item) for item in files),
1264
+ page=page,
1265
+ action_version=TURN_FILE_ACTION_VERSION,
1266
+ additions=additions,
1267
+ deletions=deletions,
1268
+ ),
1269
+ )
1270
+ )
1271
+
1272
+
1273
+ def _validate_turn_card_size(
1274
+ card: Mapping[str, Any],
1275
+ *,
1276
+ label: str,
1277
+ untruncated: str,
1278
+ ) -> None:
1279
+ # Match the Channel SDK's actual outbound Card serialization rather than
1280
+ # undercounting with compact separators.
1281
+ encoded_size = len(json.dumps(card, ensure_ascii=False).encode("utf-8"))
1282
+ if encoded_size > TURN_FILE_CARD_JSON_LIMIT_BYTES:
1283
+ raise TurnFileCardLimitError(
1284
+ f"{label}编码后为 {encoded_size} bytes,"
1285
+ "超过已验证的平台安全上限 "
1286
+ f"{TURN_FILE_CARD_JSON_LIMIT_BYTES} bytes;未截断{untruncated}。"
1287
+ )
1288
+
1289
+
1290
+ def is_turn_file_action(value: Any) -> bool:
1291
+ if not isinstance(value, Mapping):
1292
+ return False
1293
+ return value.get("intent") in {item.value for item in TurnFileActionName}
1294
+
1295
+
1296
+ def decode_turn_file_action(
1297
+ *,
1298
+ app_id: str,
1299
+ message_id: str,
1300
+ callback_chat_id: str,
1301
+ sender_id: str,
1302
+ tag: str,
1303
+ value: Any,
1304
+ form_value: Any = None,
1305
+ ) -> TurnFileActionIntent:
1306
+ if tag != "button":
1307
+ raise CardActionError(f"不支持的本轮文件组件:{tag or 'unknown'}")
1308
+ if not message_id or not callback_chat_id or not sender_id:
1309
+ raise CardActionError("本轮文件回调缺少消息、聊天或操作者标识。")
1310
+ if not isinstance(value, Mapping):
1311
+ raise CardActionError("本轮文件动作 value 必须是对象。")
1312
+ payload = dict(value)
1313
+ payload.pop("nonce", None)
1314
+ try:
1315
+ name = TurnFileActionName(payload.get("intent"))
1316
+ except (TypeError, ValueError) as error:
1317
+ raise CardActionError("未知本轮文件动作。") from error
1318
+ select_page = name is TurnFileActionName.PAGE and "pagination" in payload
1319
+ if select_page and payload.pop("pagination") != "select":
1320
+ raise CardActionError("本轮文件分页方式无效。")
1321
+ if not select_page and form_value is not None:
1322
+ raise CardActionError("本轮文件动作不接受表单字段。")
1323
+ version = payload.get("v")
1324
+ if (
1325
+ isinstance(version, bool)
1326
+ or not isinstance(version, int)
1327
+ or version
1328
+ not in {TURN_FILE_ACTION_VERSION, REPLY_CARD_ACTION_VERSION}
1329
+ ):
1330
+ raise CardActionError("本轮文件卡片已过期,请重新执行任务。")
1331
+ try:
1332
+ scope_kind = ScopeKind(payload.get("scope_kind"))
1333
+ except (TypeError, ValueError) as error:
1334
+ raise CardActionError("未知 Scope kind。") from error
1335
+ common = {
1336
+ "v",
1337
+ "intent",
1338
+ "chat_id",
1339
+ "scope_kind",
1340
+ "binding_id",
1341
+ "turn_id",
1342
+ }
1343
+ scope_fields = {"topic_id"} if scope_kind is ScopeKind.TOPIC else set()
1344
+ action_fields = {"path"}
1345
+ allowed_action_fields = (action_fields,)
1346
+ if name is TurnFileActionName.PAGE:
1347
+ if version == TURN_FILE_ACTION_VERSION:
1348
+ action_fields = {"page", "files", "answer"}
1349
+ allowed_action_fields = (
1350
+ action_fields,
1351
+ action_fields | {"a", "d"},
1352
+ action_fields | {"progress"},
1353
+ action_fields | {"progress", "a", "d"},
1354
+ )
1355
+ else:
1356
+ action_fields = {"page", "files", "reply"}
1357
+ allowed_action_fields = (
1358
+ action_fields,
1359
+ action_fields | {"a", "d"},
1360
+ )
1361
+ if not any(
1362
+ set(payload) == common | scope_fields | fields
1363
+ for fields in allowed_action_fields
1364
+ ):
1365
+ raise CardActionError("本轮文件动作字段不完整或包含未知字段。")
1366
+ if payload["chat_id"] != callback_chat_id:
1367
+ raise CardActionError("本轮文件卡片与当前聊天不一致。")
1368
+ scope = _scope_from_envelope(
1369
+ app_id=app_id,
1370
+ chat_id=callback_chat_id,
1371
+ kind=scope_kind,
1372
+ topic_id=payload.get("topic_id"),
1373
+ )
1374
+ binding_id = _decode_binding_reference(
1375
+ _required_string(payload["binding_id"], "binding_id")
1376
+ )
1377
+ turn_id = _decode_turn_reference(
1378
+ _required_string(payload["turn_id"], "turn_id")
1379
+ )
1380
+ page = None
1381
+ path = None
1382
+ files: tuple[TurnFileManifestItem, ...] = ()
1383
+ answer = None
1384
+ progress = None
1385
+ reply = None
1386
+ additions = None
1387
+ deletions = None
1388
+ if name is TurnFileActionName.PAGE:
1389
+ raw_page = payload["page"]
1390
+ if (
1391
+ isinstance(raw_page, bool)
1392
+ or not isinstance(raw_page, int)
1393
+ or raw_page < 0
1394
+ ):
1395
+ raise CardActionError("本轮文件页码必须是非负整数。")
1396
+ page = raw_page
1397
+ files = _decode_turn_file_manifest(payload["files"])
1398
+ total_pages = (len(files) + TURN_FILE_PAGE_SIZE - 1) // TURN_FILE_PAGE_SIZE
1399
+ if raw_page >= total_pages:
1400
+ raise CardActionError("本轮文件页码超出范围。")
1401
+ if select_page:
1402
+ if (
1403
+ not isinstance(form_value, Mapping)
1404
+ or set(form_value) != {"turn_file_page"}
1405
+ ):
1406
+ raise CardActionError("本轮文件选页表单字段无效。")
1407
+ selection = form_value["turn_file_page"]
1408
+ # Match the exact advertised ASCII values; reject coercion,
1409
+ # oversized numbers and options outside this complete manifest.
1410
+ if not isinstance(selection, str) or selection not in {
1411
+ str(index) for index in range(total_pages)
1412
+ }:
1413
+ raise CardActionError("本轮文件页码超出范围。")
1414
+ page = int(selection)
1415
+ if "a" in payload:
1416
+ try:
1417
+ additions, deletions = _optional_line_counts(
1418
+ payload["a"],
1419
+ payload["d"],
1420
+ field="files",
1421
+ )
1422
+ except ValueError as error:
1423
+ raise CardActionError("本轮文件总行数统计无效。") from error
1424
+ if version == TURN_FILE_ACTION_VERSION:
1425
+ answer = _required_string(payload["answer"], "answer")
1426
+ if len(answer) > 100_000 or "\x00" in answer:
1427
+ raise CardActionError("本轮文件卡片回答内容无效。")
1428
+ if "progress" in payload:
1429
+ progress = _decode_turn_progress_manifest(payload["progress"])
1430
+ else:
1431
+ reply = _decode_reply_card_manifest(
1432
+ payload["reply"],
1433
+ binding_id=binding_id,
1434
+ )
1435
+ if reply.result is None:
1436
+ raise CardActionError("组合回复分页缺少结果模块。")
1437
+ answer = reply.result.content
1438
+ progress = (
1439
+ reply.activity.progress if reply.activity is not None else None
1440
+ )
1441
+ else:
1442
+ path = _decode_turn_file_path(payload["path"])
1443
+ return TurnFileActionIntent(
1444
+ scope=scope,
1445
+ source_id=message_id,
1446
+ sender_id=sender_id,
1447
+ name=name,
1448
+ binding_id=binding_id,
1449
+ turn_id=turn_id,
1450
+ page=page,
1451
+ path=path,
1452
+ files=files,
1453
+ answer=answer,
1454
+ progress=progress,
1455
+ reply=reply,
1456
+ additions=additions,
1457
+ deletions=deletions,
1458
+ )
1459
+
1460
+
1461
+ def _decode_turn_file_manifest(value: Any) -> tuple[TurnFileManifestItem, ...]:
1462
+ if (
1463
+ not isinstance(value, list)
1464
+ or not value
1465
+ or len(value) > TURN_FILE_MANIFEST_LIMIT
1466
+ ):
1467
+ raise CardActionError(
1468
+ f"本轮文件清单必须包含 1–{TURN_FILE_MANIFEST_LIMIT} 个文件。"
1469
+ )
1470
+ results: list[TurnFileManifestItem] = []
1471
+ seen: set[str] = set()
1472
+ for item in value:
1473
+ if not isinstance(item, Mapping) or set(item) - {"deleted"} not in (
1474
+ {"path", "label"},
1475
+ {"path", "label", "a", "d"},
1476
+ ):
1477
+ raise CardActionError("本轮文件清单条目字段无效。")
1478
+ deleted = item.get("deleted", False)
1479
+ if type(deleted) is not bool:
1480
+ raise CardActionError("本轮文件删除标记无效。")
1481
+ path = _decode_turn_file_path(item["path"])
1482
+ label = _required_string(item["label"], "label")
1483
+ if len(label) > 1024 or "\x00" in label:
1484
+ raise CardActionError("本轮文件显示名称无效。")
1485
+ if path in seen:
1486
+ raise CardActionError("本轮文件清单包含重复路径。")
1487
+ seen.add(path)
1488
+ additions = None
1489
+ deletions = None
1490
+ if "a" in item:
1491
+ try:
1492
+ additions, deletions = _optional_line_counts(
1493
+ item["a"],
1494
+ item["d"],
1495
+ field="file",
1496
+ )
1497
+ except ValueError as error:
1498
+ raise CardActionError("本轮文件行数统计无效。") from error
1499
+ results.append(
1500
+ TurnFileManifestItem(
1501
+ path=path,
1502
+ label=label,
1503
+ additions=additions,
1504
+ deletions=deletions,
1505
+ deleted=deleted,
1506
+ )
1507
+ )
1508
+ return tuple(results)
1509
+
1510
+
1511
+ def _decode_turn_progress_manifest(value: Any) -> TurnProgressManifest:
1512
+ if not isinstance(value, Mapping):
1513
+ raise CardActionError("进度卡过程字段无效。")
1514
+ payload = dict(value)
1515
+ expected = {
1516
+ "state",
1517
+ "steer_count",
1518
+ "plan_available",
1519
+ "plan_generated",
1520
+ "plan_may_be_stale",
1521
+ "steps",
1522
+ "commentary",
1523
+ "operations",
1524
+ }
1525
+ if set(payload) != expected:
1526
+ raise CardActionError("进度卡过程字段不完整或包含未知字段。")
1527
+ state = _required_string(payload["state"], "progress.state")
1528
+ if state not in ACTIVE_STATE_VALUES:
1529
+ raise CardActionError("进度卡过程状态无效。")
1530
+ steer_count = payload["steer_count"]
1531
+ if (
1532
+ isinstance(steer_count, bool)
1533
+ or not isinstance(steer_count, int)
1534
+ or not 0 <= steer_count <= 1_000_000
1535
+ ):
1536
+ raise CardActionError("进度卡调整次数无效。")
1537
+ flags: dict[str, bool] = {}
1538
+ for field in (
1539
+ "plan_available",
1540
+ "plan_generated",
1541
+ "plan_may_be_stale",
1542
+ ):
1543
+ raw = payload[field]
1544
+ if type(raw) is not bool:
1545
+ raise CardActionError("进度卡计划状态无效。")
1546
+ flags[field] = raw
1547
+ raw_steps = payload["steps"]
1548
+ if not isinstance(raw_steps, list) or len(raw_steps) > _TURN_PROGRESS_MAX_STEPS:
1549
+ raise CardActionError("进度卡计划步骤无效。")
1550
+ steps: list[TurnProgressManifestStep] = []
1551
+ for raw in raw_steps:
1552
+ if not isinstance(raw, Mapping) or set(raw) != {"step", "status"}:
1553
+ raise CardActionError("进度卡计划步骤字段无效。")
1554
+ step = _required_string(raw["step"], "progress.step")
1555
+ if len(step) > _TURN_PROGRESS_STEP_MAX_CHARS or "\x00" in step:
1556
+ raise CardActionError("进度卡计划步骤内容无效。")
1557
+ status = _required_string(raw["status"], "progress.status")
1558
+ if status not in {"pending", "inProgress", "completed"}:
1559
+ raise CardActionError("进度卡计划步骤状态无效。")
1560
+ steps.append(
1561
+ TurnProgressManifestStep(
1562
+ step=activity_step_display(step),
1563
+ status=status,
1564
+ )
1565
+ )
1566
+ if not flags["plan_generated"] and steps:
1567
+ raise CardActionError("未生成计划的进度卡不能携带步骤。")
1568
+ raw_commentary = payload["commentary"]
1569
+ if (
1570
+ not isinstance(raw_commentary, list)
1571
+ or len(raw_commentary) > ACTIVITY_COMMENTARY_LIMIT
1572
+ ):
1573
+ raise CardActionError("进度卡进展摘要无效。")
1574
+ commentary: list[TurnCommentaryManifestEntry] = []
1575
+ for raw in raw_commentary:
1576
+ event_timestamp_ms: int | None
1577
+ if isinstance(raw, str):
1578
+ text = _required_string(raw, "progress.commentary")
1579
+ event_timestamp_ms = None
1580
+ elif isinstance(raw, Mapping) and set(raw) == {
1581
+ "text",
1582
+ "event_timestamp_ms",
1583
+ }:
1584
+ text = _required_string(raw["text"], "progress.commentary.text")
1585
+ event_timestamp_ms = _decode_activity_timestamp_ms(
1586
+ raw["event_timestamp_ms"],
1587
+ "progress.commentary.event_timestamp_ms",
1588
+ )
1589
+ else:
1590
+ raise CardActionError("进度卡进展摘要字段无效。")
1591
+ sanitized = sanitize_activity_text(text)
1592
+ if sanitized is None or len(text) > _TURN_PROGRESS_STEP_MAX_CHARS:
1593
+ raise CardActionError("进度卡进展摘要内容无效。")
1594
+ commentary.append(
1595
+ TurnCommentaryManifestEntry(
1596
+ text=sanitized,
1597
+ event_timestamp_ms=event_timestamp_ms,
1598
+ )
1599
+ )
1600
+ raw_operations = payload["operations"]
1601
+ if (
1602
+ not isinstance(raw_operations, list)
1603
+ or len(raw_operations) > ACTIVITY_OPERATION_LIMIT
1604
+ ):
1605
+ raise CardActionError("进度卡操作清单无效。")
1606
+ operations: list[TurnActivityManifestEntry] = []
1607
+ allowed_kinds = {
1608
+ kind.value for kind in TurnActivityKind if kind is not TurnActivityKind.COMMENTARY
1609
+ }
1610
+ allowed_statuses = {status.value for status in TurnActivityStatus}
1611
+ for raw in raw_operations:
1612
+ if not isinstance(raw, Mapping):
1613
+ raise CardActionError("进度卡操作字段无效。")
1614
+ fields = set(raw)
1615
+ legacy_fields = {"kind", "status", "text", "count"}
1616
+ timestamped_fields = legacy_fields | {"event_timestamp_ms"}
1617
+ if frozenset(fields) not in {
1618
+ frozenset(legacy_fields),
1619
+ frozenset(timestamped_fields),
1620
+ }:
1621
+ raise CardActionError("进度卡操作字段无效。")
1622
+ kind = _required_string(raw["kind"], "progress.operation.kind")
1623
+ status = _required_string(raw["status"], "progress.operation.status")
1624
+ if kind not in allowed_kinds or status not in allowed_statuses:
1625
+ raise CardActionError("进度卡操作类型或状态无效。")
1626
+ text_value = raw["text"]
1627
+ text: str | None
1628
+ if text_value is None:
1629
+ text = None
1630
+ else:
1631
+ text = _required_string(text_value, "progress.operation.text")
1632
+ try:
1633
+ text = _activity_operation_text(kind, text)
1634
+ except ValueError as error:
1635
+ raise CardActionError("进度卡操作内容无效。") from error
1636
+ event_timestamp_ms = (
1637
+ _decode_activity_timestamp_ms(
1638
+ raw["event_timestamp_ms"],
1639
+ "progress.operation.event_timestamp_ms",
1640
+ )
1641
+ if "event_timestamp_ms" in raw
1642
+ else None
1643
+ )
1644
+ count = raw["count"]
1645
+ if isinstance(count, bool) or not isinstance(count, int) or not 0 <= count <= 1_000_000:
1646
+ raise CardActionError("进度卡操作数量无效。")
1647
+ operations.append(
1648
+ TurnActivityManifestEntry(
1649
+ kind=kind,
1650
+ status=status,
1651
+ event_timestamp_ms=event_timestamp_ms,
1652
+ text=text,
1653
+ count=count,
1654
+ )
1655
+ )
1656
+ return TurnProgressManifest(
1657
+ state=state,
1658
+ steer_count=steer_count,
1659
+ plan_available=flags["plan_available"],
1660
+ plan_generated=flags["plan_generated"],
1661
+ plan_may_be_stale=flags["plan_may_be_stale"],
1662
+ steps=tuple(steps),
1663
+ commentary=tuple(commentary),
1664
+ operations=tuple(operations),
1665
+ )
1666
+
1667
+
1668
+ def _decode_reply_card_manifest(
1669
+ value: Any,
1670
+ *,
1671
+ binding_id: str,
1672
+ ) -> ReplyCardManifest:
1673
+ if not isinstance(value, Mapping) or set(value) != {
1674
+ "goal",
1675
+ "activity",
1676
+ "result",
1677
+ }:
1678
+ raise CardActionError("组合回复清单字段不完整或包含未知字段。")
1679
+ goal = _decode_reply_goal_module(value["goal"], binding_id=binding_id)
1680
+ activity = _decode_reply_activity_module(value["activity"])
1681
+ result = _decode_reply_result_module(value["result"])
1682
+ if goal is None and activity is None and result is None:
1683
+ raise CardActionError("组合回复清单不能为空。")
1684
+ return ReplyCardManifest(goal=goal, activity=activity, result=result)
1685
+
1686
+
1687
+ def _decode_reply_goal_module(
1688
+ value: Any,
1689
+ *,
1690
+ binding_id: str,
1691
+ ) -> ReplyCardGoalModule | None:
1692
+ if value is None:
1693
+ return None
1694
+ expected = {
1695
+ "binding_id",
1696
+ "short_id",
1697
+ "project_alias",
1698
+ "goal_generation",
1699
+ "status",
1700
+ "runtime_state",
1701
+ "objective",
1702
+ "token_budget",
1703
+ "tokens_used",
1704
+ "notice",
1705
+ "notice_is_error",
1706
+ }
1707
+ if not isinstance(value, Mapping) or set(value) != expected:
1708
+ raise CardActionError("Goal 模块字段不完整或包含未知字段。")
1709
+ manifest_binding_id = _decode_binding_reference(
1710
+ _required_string(value["binding_id"], "goal.binding_id")
1711
+ )
1712
+ if manifest_binding_id != binding_id:
1713
+ raise CardActionError("Goal 与 Files 模块的会话身份不一致。")
1714
+ status = value["status"]
1715
+ if status is not None:
1716
+ status = _required_string(status, "goal.status")
1717
+ if status not in {item.value for item in GoalStatus}:
1718
+ raise CardActionError("Goal 模块状态无效。")
1719
+ generation = value["goal_generation"]
1720
+ if status is None:
1721
+ if generation is not None:
1722
+ raise CardActionError("空 Goal 模块不能携带 generation。")
1723
+ else:
1724
+ generation = _decode_goal_generation(generation)
1725
+ runtime_state = _optional_bounded_string(
1726
+ value["runtime_state"], "goal.runtime_state", 128
1727
+ )
1728
+ objective = _optional_bounded_string(
1729
+ value["objective"], "goal.objective", 10_000
1730
+ )
1731
+ token_budget = _optional_nonnegative_int(
1732
+ value["token_budget"], "goal.token_budget"
1733
+ )
1734
+ tokens_used = _decode_nonnegative_int(value["tokens_used"], "goal.tokens_used")
1735
+ notice = _optional_bounded_string(value["notice"], "goal.notice", 4_000)
1736
+ notice_is_error = value["notice_is_error"]
1737
+ if type(notice_is_error) is not bool:
1738
+ raise CardActionError("Goal 模块 notice_is_error 无效。")
1739
+ if status is None and any(
1740
+ item is not None
1741
+ for item in (runtime_state, objective, token_budget)
1742
+ ):
1743
+ raise CardActionError("空 Goal 模块不能携带 Goal 状态。")
1744
+ if status is None and tokens_used != 0:
1745
+ raise CardActionError("空 Goal 模块不能携带 Token 用量。")
1746
+ if status is not None and objective is None:
1747
+ raise CardActionError("Goal 模块缺少 Objective。")
1748
+ goal = ReplyCardGoalModule(
1749
+ binding_id=binding_id,
1750
+ short_id=_bounded_decode_string(value["short_id"], "goal.short_id", 32),
1751
+ project_alias=_bounded_decode_string(
1752
+ value["project_alias"], "goal.project_alias", 128
1753
+ ),
1754
+ goal_generation=generation,
1755
+ status=status,
1756
+ runtime_state=runtime_state,
1757
+ objective=objective,
1758
+ token_budget=token_budget,
1759
+ tokens_used=tokens_used,
1760
+ notice=notice,
1761
+ notice_is_error=notice_is_error,
1762
+ )
1763
+ try:
1764
+ return _normalize_goal_module(goal)
1765
+ except ValueError as error:
1766
+ raise CardActionError("Goal 模块内容无效。") from error
1767
+
1768
+
1769
+ def _decode_reply_activity_module(value: Any) -> ReplyCardActivityModule | None:
1770
+ if value is None:
1771
+ return None
1772
+ if not isinstance(value, Mapping) or set(value) != {
1773
+ "progress",
1774
+ "terminal_status",
1775
+ "collapsed",
1776
+ "hidden_steps",
1777
+ }:
1778
+ raise CardActionError("Activity 模块字段不完整或包含未知字段。")
1779
+ progress = _decode_turn_progress_manifest(value["progress"])
1780
+ terminal_status = value["terminal_status"]
1781
+ if terminal_status is not None:
1782
+ terminal_status = _required_string(
1783
+ terminal_status, "activity.terminal_status"
1784
+ )
1785
+ try:
1786
+ terminal_status = _terminal_progress_status(terminal_status)
1787
+ except ValueError as error:
1788
+ raise CardActionError("Activity 模块终态无效。") from error
1789
+ collapsed = value["collapsed"]
1790
+ if type(collapsed) is not bool:
1791
+ raise CardActionError("Activity 模块折叠状态无效。")
1792
+ if collapsed != (terminal_status is not None):
1793
+ raise CardActionError("Activity 模块折叠状态与终态不一致。")
1794
+ hidden_steps = _decode_nonnegative_int(
1795
+ value["hidden_steps"], "activity.hidden_steps"
1796
+ )
1797
+ return ReplyCardActivityModule(
1798
+ progress=progress,
1799
+ terminal_status=terminal_status,
1800
+ collapsed=collapsed,
1801
+ hidden_steps=hidden_steps,
1802
+ )
1803
+
1804
+
1805
+ def _decode_reply_result_module(value: Any) -> ReplyCardResultModule | None:
1806
+ if value is None:
1807
+ return None
1808
+ if not isinstance(value, Mapping) or set(value) != {"content"}:
1809
+ raise CardActionError("Result 模块字段不完整或包含未知字段。")
1810
+ return ReplyCardResultModule(
1811
+ _bounded_decode_string(value["content"], "result.content", 100_000)
1812
+ )
1813
+
1814
+
1815
+ def _bounded_decode_string(value: Any, field: str, limit: int) -> str:
1816
+ result = _required_string(value, field)
1817
+ if len(result) > limit or "\x00" in result:
1818
+ raise CardActionError(f"{field} 内容无效。")
1819
+ return result
1820
+
1821
+
1822
+ def _optional_bounded_string(
1823
+ value: Any,
1824
+ field: str,
1825
+ limit: int,
1826
+ ) -> str | None:
1827
+ if value is None:
1828
+ return None
1829
+ return _bounded_decode_string(value, field, limit)
1830
+
1831
+
1832
+ def _decode_nonnegative_int(value: Any, field: str) -> int:
1833
+ if (
1834
+ isinstance(value, bool)
1835
+ or not isinstance(value, int)
1836
+ or not 0 <= value <= 10**18
1837
+ ):
1838
+ raise CardActionError(f"{field} 数值无效。")
1839
+ return value
1840
+
1841
+
1842
+ def _optional_nonnegative_int(value: Any, field: str) -> int | None:
1843
+ if value is None:
1844
+ return None
1845
+ return _decode_nonnegative_int(value, field)
1846
+
1847
+
1848
+ def _decode_turn_file_path(value: Any) -> str:
1849
+ path = _required_string(value, "path")
1850
+ if len(path) > 8192 or "\x00" in path or not Path(path).is_absolute():
1851
+ raise CardActionError("本轮文件路径必须是有效的绝对路径。")
1852
+ return path
1853
+
1854
+
1855
+ def _turn_answer_block(result: ReplyCardResultModule) -> dict[str, Any]:
1856
+ elements: list[dict[str, Any]] = [
1857
+ {"tag": "markdown", "content": result.content},
1858
+ ]
1859
+ if result.completion_mention_user_id is not None:
1860
+ elements.append({
1861
+ "tag": "markdown",
1862
+ "content": f"<at id={result.completion_mention_user_id}></at>",
1863
+ })
1864
+ return {
1865
+ "tag": "column_set",
1866
+ "element_id": _TURN_ANSWER_ELEMENT_ID,
1867
+ "flex_mode": "none",
1868
+ "background_style": "default",
1869
+ "columns": [
1870
+ {
1871
+ "tag": "column",
1872
+ "width": "weighted",
1873
+ "weight": 1,
1874
+ "padding": "12px",
1875
+ "vertical_spacing": "4px",
1876
+ "elements": elements,
1877
+ }
1878
+ ],
1879
+ }
1880
+
1881
+
1882
+ def _turn_files_blocks(
1883
+ *,
1884
+ scope: FeishuScope,
1885
+ binding_id: str,
1886
+ turn_id: str,
1887
+ page: TurnFilePage,
1888
+ manifest: tuple[TurnFileManifestItem, ...],
1889
+ final_response: str,
1890
+ progress: TurnProgressManifest | None = None,
1891
+ reply: ReplyCardManifest | None = None,
1892
+ action_version: int = TURN_FILE_ACTION_VERSION,
1893
+ additions: int | None = None,
1894
+ deletions: int | None = None,
1895
+ ) -> tuple[dict[str, Any], ...]:
1896
+ line_counts = ""
1897
+ if additions is not None and deletions is not None:
1898
+ line_counts = (
1899
+ "\n累计修改 "
1900
+ f"<font color='green'>+{additions}</font> "
1901
+ f"<font color='red'>-{deletions}</font>"
1902
+ )
1903
+ elements: list[dict[str, Any]] = [
1904
+ {
1905
+ "tag": "markdown",
1906
+ "content": (
1907
+ f"**本轮文件** · 共 {page.total_items} 个 · "
1908
+ f"第 {page.page + 1}/{page.total_pages} 页"
1909
+ f"{line_counts}\n"
1910
+ "<font color='grey'>行数累计成功的文件修改,重复修改会重复计数;"
1911
+ "统计不完整时省略总计或相应文件的数字。</font>\n"
1912
+ "<font color='grey'>点击“发送”后,文件将以图片或文件消息发送到本卡片话题。</font>"
1913
+ ),
1914
+ }
1915
+ ]
1916
+ elements.extend(
1917
+ _turn_file_row(
1918
+ scope=scope,
1919
+ binding_id=binding_id,
1920
+ turn_id=turn_id,
1921
+ turn_file=turn_file,
1922
+ action_version=action_version,
1923
+ )
1924
+ for turn_file in page.items
1925
+ )
1926
+ navigation = None
1927
+ if page.total_pages > 1:
1928
+ navigation = _turn_file_pagination(
1929
+ scope=scope,
1930
+ binding_id=binding_id,
1931
+ turn_id=turn_id,
1932
+ page=page.page,
1933
+ total_pages=page.total_pages,
1934
+ manifest=manifest,
1935
+ final_response=final_response,
1936
+ progress=progress,
1937
+ reply=reply,
1938
+ action_version=action_version,
1939
+ additions=additions,
1940
+ deletions=deletions,
1941
+ )
1942
+ block = {
1943
+ "tag": "column_set",
1944
+ "element_id": _TURN_FILES_ELEMENT_ID,
1945
+ "flex_mode": "none",
1946
+ "background_style": "grey-50",
1947
+ "columns": [
1948
+ {
1949
+ "tag": "column",
1950
+ "width": "weighted",
1951
+ "weight": 1,
1952
+ "padding": "12px",
1953
+ "vertical_spacing": "8px",
1954
+ "elements": elements,
1955
+ }
1956
+ ],
1957
+ }
1958
+ # Feishu only permits forms at the card root. Keep file SEND buttons
1959
+ # outside the form so they do not submit or validate its page selector.
1960
+ if navigation is not None:
1961
+ return block, navigation
1962
+ return (block,)
1963
+
1964
+
1965
+ def _turn_file_row(
1966
+ *,
1967
+ scope: FeishuScope,
1968
+ binding_id: str,
1969
+ turn_id: str,
1970
+ turn_file: TurnFile,
1971
+ action_version: int = TURN_FILE_ACTION_VERSION,
1972
+ ) -> dict[str, Any]:
1973
+ line_counts = ""
1974
+ if (
1975
+ (turn_file.media_kind == "file" or turn_file.deleted)
1976
+ and turn_file.additions is not None
1977
+ and turn_file.deletions is not None
1978
+ ):
1979
+ line_counts = (
1980
+ " "
1981
+ f"<font color='green'>+{turn_file.additions}</font> "
1982
+ f"<font color='red'>-{turn_file.deletions}</font>"
1983
+ )
1984
+ if not turn_file.available:
1985
+ icon = "📄" if turn_file.deleted else "⚠️"
1986
+ status = "已删除" if turn_file.deleted else "文件当前不可用"
1987
+ return {
1988
+ "tag": "column_set",
1989
+ "flex_mode": "none",
1990
+ "background_style": "default",
1991
+ "columns": [
1992
+ {
1993
+ "tag": "column",
1994
+ "width": "weighted",
1995
+ "weight": 1,
1996
+ "padding": "8px",
1997
+ "vertical_spacing": "2px",
1998
+ "elements": [
1999
+ {
2000
+ "tag": "markdown",
2001
+ "content": (
2002
+ f"{icon} `{_turn_file_label(turn_file.display_path)}`{line_counts}\n"
2003
+ f"<font color='grey'>{status}</font>"
2004
+ ),
2005
+ }
2006
+ ],
2007
+ }
2008
+ ],
2009
+ }
2010
+ assert turn_file.size is not None
2011
+ assert turn_file.media_kind is not None
2012
+ icon = "🖼️" if turn_file.media_kind == "image" else "📄"
2013
+ return {
2014
+ "tag": "column_set",
2015
+ "flex_mode": "none",
2016
+ "background_style": "default",
2017
+ "columns": [
2018
+ {
2019
+ "tag": "column",
2020
+ "width": "weighted",
2021
+ "weight": 5,
2022
+ "padding": "8px",
2023
+ "vertical_spacing": "2px",
2024
+ "elements": [
2025
+ {
2026
+ "tag": "markdown",
2027
+ "content": f"{icon} `{_turn_file_label(turn_file.display_path)}`{line_counts}",
2028
+ }
2029
+ ],
2030
+ },
2031
+ {
2032
+ "tag": "column",
2033
+ "width": "auto",
2034
+ "vertical_align": "center",
2035
+ "padding": "8px",
2036
+ "elements": [
2037
+ _callback_button(
2038
+ label="发送",
2039
+ value=_turn_file_envelope(
2040
+ scope,
2041
+ TurnFileActionName.SEND,
2042
+ version=action_version,
2043
+ binding_id=_binding_reference(binding_id),
2044
+ turn_id=_turn_reference(turn_id),
2045
+ path=str(turn_file.resolved_path),
2046
+ ),
2047
+ )
2048
+ ],
2049
+ },
2050
+ ],
2051
+ }
2052
+
2053
+
2054
+ def _encode_turn_file_manifest_item(
2055
+ item: TurnFileManifestItem,
2056
+ ) -> dict[str, Any]:
2057
+ encoded: dict[str, Any] = {"path": item.path, "label": item.label}
2058
+ if item.deleted:
2059
+ encoded["deleted"] = True
2060
+ if item.additions is not None and item.deletions is not None:
2061
+ encoded["a"] = item.additions
2062
+ encoded["d"] = item.deletions
2063
+ return encoded
2064
+
2065
+
2066
+ def _turn_file_pagination(
2067
+ *,
2068
+ scope: FeishuScope,
2069
+ binding_id: str,
2070
+ turn_id: str,
2071
+ page: int,
2072
+ total_pages: int,
2073
+ manifest: tuple[TurnFileManifestItem, ...],
2074
+ final_response: str,
2075
+ progress: TurnProgressManifest | None,
2076
+ reply: ReplyCardManifest | None,
2077
+ action_version: int,
2078
+ additions: int | None,
2079
+ deletions: int | None,
2080
+ ) -> dict[str, Any]:
2081
+ page_value: dict[str, Any] = {
2082
+ "binding_id": _binding_reference(binding_id),
2083
+ "turn_id": _turn_reference(turn_id),
2084
+ "pagination": "select",
2085
+ "files": [
2086
+ _encode_turn_file_manifest_item(item)
2087
+ for item in manifest
2088
+ ],
2089
+ }
2090
+ if additions is not None and deletions is not None:
2091
+ page_value["a"] = additions
2092
+ page_value["d"] = deletions
2093
+ if action_version == TURN_FILE_ACTION_VERSION:
2094
+ page_value["answer"] = final_response
2095
+ if progress is not None:
2096
+ page_value["progress"] = _encode_turn_progress_manifest(progress)
2097
+ else:
2098
+ if reply is None:
2099
+ raise ValueError("v5 pagination requires a Reply Card manifest")
2100
+ page_value["reply"] = _encode_reply_card_manifest(reply)
2101
+ button = _repeatable_callback_button(
2102
+ label="跳转",
2103
+ value=_turn_file_envelope(
2104
+ scope, TurnFileActionName.PAGE,
2105
+ version=action_version, page=page, **page_value,
2106
+ ),
2107
+ )
2108
+ button.update(name="turn_file_jump", form_action_type="submit")
2109
+ # The Files header already shows current/total pages. Use the selector
2110
+ # itself as the footer's current-page label, preserving capacity.
2111
+ columns = [
2112
+ {
2113
+ "tag": "column",
2114
+ "width": "auto",
2115
+ "elements": [{
2116
+ "tag": "select_static",
2117
+ "name": "turn_file_page",
2118
+ "required": True,
2119
+ "initial_option": str(page),
2120
+ "placeholder": _plain_text("选择页码"),
2121
+ "options": [
2122
+ {"text": _plain_text(f"第{index + 1}页"), "value": str(index)}
2123
+ for index in range(total_pages)
2124
+ ],
2125
+ }],
2126
+ },
2127
+ {"tag": "column", "width": "auto", "elements": [button]},
2128
+ ]
2129
+ return {
2130
+ "tag": "form",
2131
+ "name": "turn_file_pagination",
2132
+ "elements": [{"tag": "column_set", "flex_mode": "none", "columns": columns}],
2133
+ }
2134
+
2135
+
2136
+ def _turn_file_envelope(
2137
+ scope: FeishuScope,
2138
+ name: TurnFileActionName,
2139
+ *,
2140
+ version: int = TURN_FILE_ACTION_VERSION,
2141
+ **extra: Any,
2142
+ ) -> dict[str, Any]:
2143
+ value = {
2144
+ "v": version,
2145
+ "intent": name.value,
2146
+ "chat_id": scope.chat_id,
2147
+ "scope_kind": scope.kind.value,
2148
+ **extra,
2149
+ }
2150
+ if scope.kind is ScopeKind.TOPIC:
2151
+ value["topic_id"] = scope.topic_id
2152
+ return value
2153
+
2154
+
2155
+ def _encode_turn_progress_manifest(
2156
+ progress: TurnProgressManifest,
2157
+ ) -> dict[str, Any]:
2158
+ return {
2159
+ "state": progress.state,
2160
+ "steer_count": progress.steer_count,
2161
+ "plan_available": progress.plan_available,
2162
+ "plan_generated": progress.plan_generated,
2163
+ "plan_may_be_stale": progress.plan_may_be_stale,
2164
+ "steps": [
2165
+ {"step": item.step, "status": item.status}
2166
+ for item in progress.steps
2167
+ ],
2168
+ "commentary": [
2169
+ (
2170
+ item.text
2171
+ if item.event_timestamp_ms is None
2172
+ else {
2173
+ "text": item.text,
2174
+ "event_timestamp_ms": item.event_timestamp_ms,
2175
+ }
2176
+ )
2177
+ for item in progress.commentary
2178
+ ],
2179
+ "operations": [
2180
+ {
2181
+ "kind": item.kind,
2182
+ "status": item.status,
2183
+ "text": item.text,
2184
+ "count": item.count,
2185
+ **(
2186
+ {}
2187
+ if item.event_timestamp_ms is None
2188
+ else {"event_timestamp_ms": item.event_timestamp_ms}
2189
+ ),
2190
+ }
2191
+ for item in progress.operations
2192
+ ],
2193
+ }
2194
+
2195
+
2196
+ def _encode_reply_card_manifest(
2197
+ reply: ReplyCardManifest,
2198
+ ) -> dict[str, Any]:
2199
+ return {
2200
+ "goal": _encode_reply_goal_module(reply.goal),
2201
+ "activity": _encode_reply_activity_module(reply.activity),
2202
+ "result": (
2203
+ None if reply.result is None else {"content": reply.result.content}
2204
+ ),
2205
+ }
2206
+
2207
+
2208
+ def _encode_reply_goal_module(
2209
+ goal: ReplyCardGoalModule | None,
2210
+ ) -> dict[str, Any] | None:
2211
+ if goal is None:
2212
+ return None
2213
+ return {
2214
+ "binding_id": _binding_reference(goal.binding_id),
2215
+ "short_id": goal.short_id,
2216
+ "project_alias": goal.project_alias,
2217
+ "goal_generation": goal.goal_generation,
2218
+ "status": goal.status,
2219
+ "runtime_state": goal.runtime_state,
2220
+ "objective": goal.objective,
2221
+ "token_budget": goal.token_budget,
2222
+ "tokens_used": goal.tokens_used,
2223
+ "notice": goal.notice,
2224
+ "notice_is_error": goal.notice_is_error,
2225
+ }
2226
+
2227
+
2228
+ def _encode_reply_activity_module(
2229
+ activity: ReplyCardActivityModule | None,
2230
+ ) -> dict[str, Any] | None:
2231
+ if activity is None:
2232
+ return None
2233
+ # Encode the sanitized frozen view even if a caller bypassed a wrapper.
2234
+ progress = _sanitize_turn_progress_manifest(activity.progress)
2235
+ return {
2236
+ "progress": _encode_turn_progress_manifest(progress),
2237
+ "terminal_status": activity.terminal_status,
2238
+ "collapsed": activity.collapsed,
2239
+ "hidden_steps": activity.hidden_steps,
2240
+ }
2241
+
2242
+
2243
+ def _turn_file_label(value: str) -> str:
2244
+ visible = "".join(
2245
+ character if character.isprintable() else "�" for character in value
2246
+ )
2247
+ return html.escape(visible, quote=False).replace("`", "ˋ")