@qingflow-tech/qingflow-app-user-mcp 1.0.2 → 1.0.4

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 (56) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +21 -12
  7. package/skills/qingflow-app-user/references/data-gotchas.md +1 -1
  8. package/skills/qingflow-app-user/references/public-surface-sync.md +70 -0
  9. package/skills/qingflow-app-user/references/record-patterns.md +1 -1
  10. package/skills/qingflow-record-analysis/SKILL.md +44 -2
  11. package/skills/qingflow-record-insert/SKILL.md +3 -0
  12. package/skills/qingflow-record-update/SKILL.md +3 -0
  13. package/skills/qingflow-task-ops/SKILL.md +31 -10
  14. package/src/qingflow_mcp/__init__.py +33 -1
  15. package/src/qingflow_mcp/backend_client.py +109 -0
  16. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +58 -9
  18. package/src/qingflow_mcp/builder_facade/service.py +1711 -240
  19. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  20. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  21. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  22. package/src/qingflow_mcp/cli/commands/builder.py +11 -3
  23. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  24. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  25. package/src/qingflow_mcp/cli/commands/task.py +701 -27
  26. package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
  27. package/src/qingflow_mcp/cli/context.py +3 -0
  28. package/src/qingflow_mcp/cli/formatters.py +424 -50
  29. package/src/qingflow_mcp/cli/interaction.py +72 -0
  30. package/src/qingflow_mcp/cli/main.py +11 -1
  31. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  32. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  33. package/src/qingflow_mcp/config.py +1 -1
  34. package/src/qingflow_mcp/errors.py +4 -4
  35. package/src/qingflow_mcp/export_store.py +14 -0
  36. package/src/qingflow_mcp/id_utils.py +49 -0
  37. package/src/qingflow_mcp/public_surface.py +16 -1
  38. package/src/qingflow_mcp/response_trim.py +394 -9
  39. package/src/qingflow_mcp/server.py +26 -0
  40. package/src/qingflow_mcp/server_app_builder.py +15 -1
  41. package/src/qingflow_mcp/server_app_user.py +113 -0
  42. package/src/qingflow_mcp/session_store.py +126 -21
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  44. package/src/qingflow_mcp/solution/executor.py +2 -2
  45. package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
  46. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  47. package/src/qingflow_mcp/tools/auth_tools.py +243 -9
  48. package/src/qingflow_mcp/tools/base.py +6 -2
  49. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  50. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  51. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  52. package/src/qingflow_mcp/tools/import_tools.py +78 -4
  53. package/src/qingflow_mcp/tools/record_tools.py +551 -165
  54. package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
  55. package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
  56. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -1,17 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import textwrap
4
5
  from typing import Any, TextIO
5
6
 
6
7
 
7
8
  def emit_text_result(result: dict[str, Any], *, hint: str, stream: TextIO) -> None:
8
- formatter = _FORMATTERS.get(hint, _format_generic)
9
- text = formatter(result)
9
+ text = _format_cancelled_result(result)
10
+ if text is None:
11
+ formatter = _FORMATTERS.get(hint, _format_generic)
12
+ text = formatter(result)
10
13
  stream.write(text)
11
14
  if not text.endswith("\n"):
12
15
  stream.write("\n")
13
16
 
14
17
 
18
+ def _format_cancelled_result(result: dict[str, Any]) -> str | None:
19
+ if str(result.get("status") or "").lower() != "cancelled":
20
+ return None
21
+ return str(result.get("message") or "已取消") + "\n"
22
+
23
+
15
24
  def _format_generic(result: dict[str, Any]) -> str:
16
25
  lines: list[str] = []
17
26
  title = _first_present(result, "status", "message")
@@ -38,8 +47,19 @@ def _format_whoami(result: dict[str, Any]) -> str:
38
47
  f"User: {result.get('nick_name') or '-'} ({result.get('email') or '-'})",
39
48
  f"UID: {result.get('uid')}",
40
49
  f"Workspace: {result.get('selected_ws_name') or '-'} ({result.get('selected_ws_id') or '-'})",
41
- f"QF Version: {result.get('qf_version') or '-'}",
50
+ f"Workspace QF Version: {result.get('qf_version') or '-'}",
42
51
  ]
52
+ cli_auth = result.get("cli_auth") if isinstance(result.get("cli_auth"), dict) else {}
53
+ if cli_auth:
54
+ lines.append(f"Login Flow: {cli_auth.get('flow') or '-'}")
55
+ if cli_auth.get("verification_uri"):
56
+ lines.append(f"Verification URL: {cli_auth.get('verification_uri')}")
57
+ if cli_auth.get("user_code"):
58
+ lines.append(f"User Code: {cli_auth.get('user_code')}")
59
+ request_route = result.get("request_route") if isinstance(result.get("request_route"), dict) else {}
60
+ route_qf_version = request_route.get("qf_version")
61
+ if route_qf_version and route_qf_version != result.get("qf_version"):
62
+ lines.append(f"Request Route QF Version: {route_qf_version}")
43
63
  lines.append(f"Permission Level: {result.get('permission_level') or '-'}")
44
64
  departments = result.get("departments") if isinstance(result.get("departments"), list) else []
45
65
  roles = result.get("roles") if isinstance(result.get("roles"), list) else []
@@ -82,6 +102,34 @@ def _format_workspace_list(result: dict[str, Any]) -> str:
82
102
  return _render_titled_table("Workspaces", ["ws_id", "name", "remark"], rows)
83
103
 
84
104
 
105
+ def _format_workspace_get(result: dict[str, Any]) -> str:
106
+ workspace = result.get("workspace") if isinstance(result.get("workspace"), dict) else {}
107
+ lines = [
108
+ f"Workspace: {workspace.get('workspaceName') or workspace.get('wsName') or '-'} ({workspace.get('wsId') or result.get('ws_id') or '-'})",
109
+ f"QF Version: {result.get('qf_version') or workspace.get('systemVersion') or '-'}",
110
+ f"Identity: {workspace.get('identity') or '-'}",
111
+ f"Auth: {workspace.get('auth') if workspace.get('auth') is not None else '-'}",
112
+ f"State: {workspace.get('state') if workspace.get('state') is not None else '-'}",
113
+ ]
114
+ _append_warnings(lines, result.get("warnings"))
115
+ return "\n".join(lines) + "\n"
116
+
117
+
118
+ def _format_workspace_select(result: dict[str, Any]) -> str:
119
+ status = str(result.get("status") or "").lower()
120
+ if status == "cancelled":
121
+ return str(result.get("message") or "已取消") + "\n"
122
+
123
+ workspace = result.get("workspace") if isinstance(result.get("workspace"), dict) else {}
124
+ workspace_name = workspace.get("workspaceName") or workspace.get("wsName") or result.get("selected", {}).get("workspace_name") if isinstance(result.get("selected"), dict) else None
125
+ lines = [
126
+ f"已切换到: {workspace_name or '-'} ({workspace.get('wsId') or result.get('ws_id') or '-'})",
127
+ f"QF Version: {result.get('qf_version') or workspace.get('systemVersion') or '-'}",
128
+ ]
129
+ _append_warnings(lines, result.get("warnings"))
130
+ return "\n".join(lines) + "\n"
131
+
132
+
85
133
  def _format_app_items(result: dict[str, Any]) -> str:
86
134
  items = result.get("items")
87
135
  if not isinstance(items, list):
@@ -118,6 +166,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
118
166
  if editability:
119
167
  lines.append(
120
168
  "Editability: "
169
+ f"app_base={editability.get('can_edit_app_base')} / "
121
170
  f"form={editability.get('can_edit_form')} / "
122
171
  f"flow={editability.get('can_edit_flow')} / "
123
172
  f"views={editability.get('can_edit_views')} / "
@@ -148,20 +197,24 @@ def _format_record_list(result: dict[str, Any]) -> str:
148
197
  def _format_task_list(result: dict[str, Any]) -> str:
149
198
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
150
199
  items = data.get("items") if isinstance(data.get("items"), list) else []
151
- rows = []
200
+ lines = ["Tasks"]
152
201
  for item in items:
153
202
  if not isinstance(item, dict):
154
203
  continue
155
- rows.append(
156
- [
157
- str(item.get("app_key") or ""),
158
- str(item.get("record_id") or ""),
159
- str(item.get("workflow_node_id") or ""),
160
- str(item.get("title") or item.get("task_name") or ""),
161
- ]
162
- )
163
- output = _render_titled_table("Tasks", ["app_key", "record_id", "node_id", "title"], rows)
164
- lines = output.rstrip("\n").split("\n")
204
+ header_parts = [
205
+ str(item.get("task_id") or "-"),
206
+ str(item.get("app_name") or item.get("app_key") or "-"),
207
+ str(item.get("workflow_node_name") or "-"),
208
+ ]
209
+ apply_time = item.get("apply_time")
210
+ if apply_time not in (None, ""):
211
+ header_parts.append(str(apply_time))
212
+ lines.append("- " + " / ".join(header_parts))
213
+ summary_fields = item.get("summary_fields") if isinstance(item.get("summary_fields"), list) else []
214
+ for summary in summary_fields:
215
+ if not isinstance(summary, dict):
216
+ continue
217
+ lines.append(f" {summary.get('title') or '-'}: {summary.get('answer') or '-'}")
165
218
  _append_warnings(lines, result.get("warnings"))
166
219
  return "\n".join(lines) + "\n"
167
220
 
@@ -169,34 +222,150 @@ def _format_task_list(result: dict[str, Any]) -> str:
169
222
  def _format_task_get(result: dict[str, Any]) -> str:
170
223
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
171
224
  task = data.get("task") if isinstance(data.get("task"), dict) else {}
172
- record = data.get("record") if isinstance(data.get("record"), dict) else {}
173
- capabilities = data.get("capabilities") if isinstance(data.get("capabilities"), dict) else {}
174
- update_schema = data.get("update_schema") if isinstance(data.get("update_schema"), dict) else {}
175
- writable_fields = update_schema.get("writable_fields") if isinstance(update_schema.get("writable_fields"), list) else []
176
- lines = [
177
- f"Task: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
178
- f"Node: {task.get('workflow_node_name') or '-'}",
179
- f"Apply Status: {record.get('apply_status')}",
180
- f"Available Actions: {', '.join(str(item) for item in (capabilities.get('available_actions') or [])) or '-'}",
181
- f"Editable Fields: {len(writable_fields)}",
182
- ]
183
- if writable_fields:
184
- for item in writable_fields[:10]:
225
+ record_summary = data.get("record_summary") if isinstance(data.get("record_summary"), dict) else {}
226
+ editable_fields = data.get("editable_fields") if isinstance(data.get("editable_fields"), list) else []
227
+ available_actions = data.get("available_actions") if isinstance(data.get("available_actions"), list) else []
228
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
229
+ initiator = task.get("initiator") if isinstance(task.get("initiator"), dict) else {}
230
+ initiator_label = initiator.get("displayName") or initiator.get("email") or "-"
231
+ lines = []
232
+ if task.get("task_id") not in (None, ""):
233
+ lines.append(f"Task ID: {task.get('task_id')}")
234
+ lines.extend(
235
+ [
236
+ f"Locator: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
237
+ f"Node: {task.get('workflow_node_name') or '-'}",
238
+ f"App: {task.get('app_name') or '-'}",
239
+ f"Initiator: {initiator_label}",
240
+ f"Apply Status: {record_summary.get('apply_status')}",
241
+ f"Apply Number: {record_summary.get('custom_apply_num') or record_summary.get('apply_num') or '-'}",
242
+ f"Apply Time: {record_summary.get('apply_time') or '-'}",
243
+ f"Available Actions: {', '.join(str(item) for item in available_actions) or '-'}",
244
+ f"Editable Fields: {len(editable_fields)}",
245
+ ]
246
+ )
247
+ all_fields = record_summary.get("all_fields") if isinstance(record_summary.get("all_fields"), dict) else {}
248
+ core_fields = record_summary.get("core_fields") if isinstance(record_summary.get("core_fields"), dict) else {}
249
+ if all_fields:
250
+ lines.append("Fields:")
251
+ for key, value in all_fields.items():
252
+ lines.extend(_format_field_line(key, value))
253
+ elif core_fields:
254
+ lines.append("Core Fields:")
255
+ for key, value in list(core_fields.items())[:12]:
256
+ lines.extend(_format_field_line(key, value))
257
+ if editable_fields:
258
+ lines.append("Editable Fields:")
259
+ for item in editable_fields[:10]:
185
260
  if isinstance(item, dict):
186
261
  lines.append(f"- {item.get('title') or '-'} ({item.get('kind') or 'field'})")
187
- blockers = update_schema.get("blockers") if isinstance(update_schema.get("blockers"), list) else []
188
- if blockers:
189
- lines.append("Update Schema Blockers:")
190
- for item in blockers:
191
- lines.append(f"- {item}")
192
- schema_warnings = update_schema.get("warnings") if isinstance(update_schema.get("warnings"), list) else []
193
- if schema_warnings:
194
- lines.append("Update Schema Warnings:")
195
- for item in schema_warnings:
262
+ associated_reports = extras.get("associated_reports") if isinstance(extras.get("associated_reports"), dict) else {}
263
+ rollback_candidates = extras.get("rollback_candidates") if isinstance(extras.get("rollback_candidates"), dict) else {}
264
+ transfer_candidates = extras.get("transfer_candidates") if isinstance(extras.get("transfer_candidates"), dict) else {}
265
+ lines.append(
266
+ "Extras: "
267
+ f"reports={associated_reports.get('count', 0)}, "
268
+ f"rollback={rollback_candidates.get('count', 0)}, "
269
+ f"transfer={transfer_candidates.get('count', 0)}"
270
+ )
271
+ transfer_items = transfer_candidates.get("items") if isinstance(transfer_candidates.get("items"), list) else []
272
+ if transfer_items:
273
+ lines.append("Transfer Candidates:")
274
+ for item in transfer_items:
196
275
  if isinstance(item, dict):
197
- lines.append(f"- {item.get('code') or 'WARNING'}: {item.get('message') or ''}".rstrip())
198
- else:
199
- lines.append(f"- {item}")
276
+ display = item.get("name") or item.get("uid") or item
277
+ suffix = f" <{item.get('email')}>" if item.get("email") else ""
278
+ lines.append(f"- {display}{suffix} (uid={item.get('uid') or '-'})")
279
+ _append_warnings(lines, result.get("warnings"))
280
+ return "\n".join(lines) + "\n"
281
+
282
+
283
+ def _format_task_action(result: dict[str, Any]) -> str:
284
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
285
+ action = str(data.get("action") or "").strip().lower()
286
+ status = str(result.get("status") or "").strip().lower()
287
+
288
+ if status == "failed" or result.get("ok") is False:
289
+ lines = [_task_action_failure_label(action)]
290
+ reason = _task_action_failure_reason(result)
291
+ if reason:
292
+ lines.append(f"原因:{reason}")
293
+ debug_lines = _task_action_debug_lines(result)
294
+ if debug_lines:
295
+ lines.append("调试信息:")
296
+ lines.extend(f"- {line}" for line in debug_lines)
297
+ return "\n".join(lines) + "\n"
298
+
299
+ if status == "partial_success":
300
+ lines = [_task_action_success_label(action)]
301
+ lines.append(f"说明:{_task_action_partial_success_message(result)}")
302
+ return "\n".join(lines) + "\n"
303
+
304
+ return _task_action_success_label(action) + "\n"
305
+
306
+
307
+ def _format_task_workbench(result: dict[str, Any]) -> str:
308
+ message = str(result.get("message") or "").strip()
309
+ if message:
310
+ return message + "\n"
311
+ return ""
312
+
313
+
314
+ def _format_field_line(key: Any, value: Any) -> list[str]:
315
+ if isinstance(value, list):
316
+ text = " / ".join(str(item) for item in value if item not in (None, ""))
317
+ else:
318
+ text = str(value if value not in (None, "") else "-")
319
+ wrapped = textwrap.wrap(
320
+ text,
321
+ width=120,
322
+ initial_indent=f"- {key}: ",
323
+ subsequent_indent=" ",
324
+ replace_whitespace=False,
325
+ drop_whitespace=False,
326
+ break_long_words=True,
327
+ )
328
+ return wrapped or [f"- {key}: -"]
329
+
330
+
331
+ def _format_task_associated_report_detail(result: dict[str, Any]) -> str:
332
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
333
+ selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
334
+ result_type = str(data.get("result_type") or "-")
335
+ context = data.get("context") if isinstance(data.get("context"), dict) else {}
336
+ lines = []
337
+ if selection.get("task_id") not in (None, ""):
338
+ lines.append(f"Task ID: {selection.get('task_id')}")
339
+ lines.extend(
340
+ [
341
+ f"Report: {selection.get('chart_name') or '-'} ({selection.get('report_id') or '-'})",
342
+ f"Type: {result_type}",
343
+ ]
344
+ )
345
+ if result_type == "view_list":
346
+ result_payload = data.get("result") if isinstance(data.get("result"), dict) else {}
347
+ items = result_payload.get("items") if isinstance(result_payload.get("items"), list) else []
348
+ lines.append(f"Returned Records: {len(items)}")
349
+ for item in items[:10]:
350
+ if isinstance(item, dict):
351
+ lines.append(json.dumps(item, ensure_ascii=False))
352
+ if len(items) > 10:
353
+ lines.append(f"... {len(items) - 10} more")
354
+ elif result_type == "chart_data":
355
+ result_payload = data.get("result") if isinstance(data.get("result"), dict) else {}
356
+ summary = result_payload.get("summary") if isinstance(result_payload.get("summary"), dict) else {}
357
+ rows = result_payload.get("rows") if isinstance(result_payload.get("rows"), list) else []
358
+ if summary:
359
+ lines.append(f"Summary: {json.dumps(summary, ensure_ascii=False)}")
360
+ lines.append(f"Rows: {len(rows)}")
361
+ for row in rows[:10]:
362
+ if isinstance(row, dict):
363
+ lines.append(json.dumps(row, ensure_ascii=False))
364
+ if len(rows) > 10:
365
+ lines.append(f"... {len(rows) - 10} more")
366
+ resolved_filters = context.get("resolved_filters") if isinstance(context.get("resolved_filters"), list) else []
367
+ if resolved_filters:
368
+ lines.append(f"Resolved Filters: {len(resolved_filters)}")
200
369
  _append_warnings(lines, result.get("warnings"))
201
370
  return "\n".join(lines) + "\n"
202
371
 
@@ -206,17 +375,22 @@ def _format_import_verify(result: dict[str, Any]) -> str:
206
375
  f"App Key: {result.get('app_key') or '-'}",
207
376
  f"File: {result.get('file_name') or result.get('file_path') or '-'}",
208
377
  f"Can Import: {result.get('can_import')}",
209
- f"Apply Rows: {result.get('apply_rows')}",
210
378
  f"Verification ID: {result.get('verification_id') or '-'}",
211
379
  ]
212
- issues = result.get("issues") if isinstance(result.get("issues"), list) else []
213
- if issues:
214
- lines.append("Issues:")
215
- for issue in issues:
216
- if isinstance(issue, dict):
217
- lines.append(f"- {issue.get('code') or 'ISSUE'}: {issue.get('message') or issue}")
218
- else:
219
- lines.append(f"- {issue}")
380
+ issue_summary = result.get("issue_summary") if isinstance(result.get("issue_summary"), dict) else {}
381
+ if issue_summary:
382
+ lines.append(
383
+ "Issues: "
384
+ f"total={issue_summary.get('total', 0)}, "
385
+ f"errors={issue_summary.get('errors', 0)}, "
386
+ f"warnings={issue_summary.get('warnings', 0)}"
387
+ )
388
+ sample = issue_summary.get("sample") if isinstance(issue_summary.get("sample"), list) else []
389
+ if sample:
390
+ lines.append("Issue Samples:")
391
+ for item in sample:
392
+ if isinstance(item, dict):
393
+ lines.append(f"- {item.get('code') or 'ISSUE'}: {item.get('message') or ''}".rstrip())
220
394
  _append_warnings(lines, result.get("warnings"))
221
395
  _append_verification(lines, result.get("verification"))
222
396
  return "\n".join(lines) + "\n"
@@ -227,15 +401,91 @@ def _format_import_status(result: dict[str, Any]) -> str:
227
401
  f"Status: {result.get('status') or '-'}",
228
402
  f"Import ID: {result.get('import_id') or '-'}",
229
403
  f"Process ID: {result.get('process_id_str') or '-'}",
230
- f"Success Rows: {result.get('success_rows') or 0}",
231
- f"Failed Rows: {result.get('failed_rows') or 0}",
404
+ f"Total Rows: {result.get('total') or 0}",
405
+ f"Finished Rows: {result.get('finished') or 0}",
406
+ f"Succeeded Rows: {result.get('succeeded') or 0}",
407
+ f"Failed Rows: {result.get('failed') or 0}",
232
408
  f"Progress: {result.get('progress') or '-'}",
233
409
  ]
410
+ if result.get("process_status") not in (None, ""):
411
+ lines.append(f"Process Status: {result.get('process_status')}")
412
+ error_file_urls = result.get("error_file_urls") if isinstance(result.get("error_file_urls"), list) else []
413
+ if error_file_urls:
414
+ lines.append("Error Files:")
415
+ for url in error_file_urls:
416
+ lines.append(f"- {url}")
234
417
  _append_warnings(lines, result.get("warnings"))
235
418
  _append_verification(lines, result.get("verification"))
236
419
  return "\n".join(lines) + "\n"
237
420
 
238
421
 
422
+ def _format_export_common(result: dict[str, Any], *, title: str | None = None) -> str:
423
+ lines: list[str] = []
424
+ if title:
425
+ lines.append(title)
426
+ lines.extend(
427
+ [
428
+ f"Status: {result.get('status') or '-'}",
429
+ f"Export Handle: {result.get('export_handle') or '-'}",
430
+ f"App Key: {result.get('app_key') or '-'}",
431
+ f"View ID: {result.get('view_id') or '-'}",
432
+ f"Process Status: {result.get('process_status') or '-'}",
433
+ f"Rows: {result.get('num') if result.get('num') is not None else '-'}",
434
+ ]
435
+ )
436
+ row_scope = result.get("row_scope")
437
+ if row_scope not in (None, ""):
438
+ lines.append(f"Row Scope: {row_scope}")
439
+ selected_record_count = result.get("selected_record_count")
440
+ if selected_record_count not in (None, ""):
441
+ lines.append(f"Selected Rows: {selected_record_count}")
442
+ field_scope = result.get("field_scope")
443
+ if field_scope not in (None, ""):
444
+ lines.append(f"Field Scope: {field_scope}")
445
+ selected_field_count = result.get("selected_field_count")
446
+ if selected_field_count not in (None, ""):
447
+ lines.append(f"Selected Fields: {selected_field_count}")
448
+ include_workflow_log = result.get("include_workflow_log")
449
+ if include_workflow_log not in (None, ""):
450
+ lines.append(f"Include Workflow Log: {include_workflow_log}")
451
+ file_names = result.get("file_names") if isinstance(result.get("file_names"), list) else []
452
+ file_urls = result.get("file_urls") if isinstance(result.get("file_urls"), list) else []
453
+ if file_names or file_urls:
454
+ lines.append("Remote Files:")
455
+ max_items = max(len(file_names), len(file_urls))
456
+ for index in range(max_items):
457
+ name = file_names[index] if index < len(file_names) else "-"
458
+ url = file_urls[index] if index < len(file_urls) else "-"
459
+ lines.append(f"- {name}: {url}")
460
+ downloaded_files = result.get("downloaded_files") if isinstance(result.get("downloaded_files"), list) else []
461
+ if downloaded_files:
462
+ lines.append("Downloaded Files:")
463
+ for item in downloaded_files:
464
+ if isinstance(item, dict):
465
+ lines.append(f"- {item.get('file_name') or '-'}: {item.get('path') or '-'}")
466
+ else:
467
+ lines.append(f"- {item}")
468
+ _append_warnings(lines, result.get("warnings"))
469
+ _append_verification(lines, result.get("verification"))
470
+ return "\n".join(lines) + "\n"
471
+
472
+
473
+ def _format_export_start(result: dict[str, Any]) -> str:
474
+ return _format_export_common(result, title="Export Accepted")
475
+
476
+
477
+ def _format_export_status(result: dict[str, Any]) -> str:
478
+ return _format_export_common(result, title="Export Status")
479
+
480
+
481
+ def _format_export_get(result: dict[str, Any]) -> str:
482
+ return _format_export_common(result, title="Export Result")
483
+
484
+
485
+ def _format_export_direct(result: dict[str, Any]) -> str:
486
+ return _format_export_common(result, title="Export Direct")
487
+
488
+
239
489
  def _format_builder_summary(result: dict[str, Any]) -> str:
240
490
  lines = []
241
491
  if "status" in result:
@@ -319,16 +569,140 @@ def _first_present(payload: dict[str, Any], *keys: str) -> Any:
319
569
  return None
320
570
 
321
571
 
572
+ def _task_action_success_label(action: str) -> str:
573
+ return {
574
+ "approve": "已通过",
575
+ "reject": "已驳回",
576
+ "rollback": "已退回",
577
+ "transfer": "已转交",
578
+ "save_only": "已保存",
579
+ "urge": "已催办",
580
+ }.get(action, "已执行")
581
+
582
+
583
+ def _task_action_failure_label(action: str) -> str:
584
+ return {
585
+ "approve": "审批失败",
586
+ "reject": "驳回失败",
587
+ "rollback": "退回失败",
588
+ "transfer": "转交失败",
589
+ "save_only": "保存失败",
590
+ "urge": "催办失败",
591
+ }.get(action, "执行失败")
592
+
593
+
594
+ def _task_action_partial_success_message(result: dict[str, Any]) -> str:
595
+ error_code = str(result.get("error_code") or "").strip().upper()
596
+ if error_code == "WORKFLOW_CONTINUATION_UNVERIFIED":
597
+ return "动作已提交,但暂未完成后续流程验证。可使用 --json 查看详细信息。"
598
+ if error_code == "TASK_ALREADY_PROCESSED":
599
+ return "当前待办已不可操作,系统判断流程可能已被其他人处理。可使用 --json 查看详细信息。"
600
+ warnings = result.get("warnings")
601
+ if isinstance(warnings, list):
602
+ for warning in warnings:
603
+ if not isinstance(warning, dict):
604
+ continue
605
+ code = str(warning.get("code") or "").strip().upper()
606
+ if code == "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR":
607
+ return "当前待办已不可操作,系统判断流程可能已被其他人处理。可使用 --json 查看详细信息。"
608
+ if code == "WORKFLOW_CONTINUATION_UNVERIFIED":
609
+ return "动作已提交,但暂未完成后续流程验证。可使用 --json 查看详细信息。"
610
+ return "动作已提交,但结果验证不完整。可使用 --json 查看详细信息。"
611
+
612
+
613
+ def _task_action_failure_reason(result: dict[str, Any]) -> str | None:
614
+ error_code = str(result.get("error_code") or "").strip().upper()
615
+ mapped_error = {
616
+ "TASK_CONTEXT_VISIBILITY_UNVERIFIED": "当前待办已不可操作,且系统未能确认是否已被处理。",
617
+ "TASK_SAVE_ONLY_VERIFICATION_FAILED": "保存请求已发送,但未能确认字段是否全部保存成功。",
618
+ "WORKFLOW_CONTINUATION_UNVERIFIED": "动作已提交,但暂未验证到流程继续推进。",
619
+ "TASK_ALREADY_PROCESSED": "当前待办已不可操作,系统判断流程可能已被其他人处理。",
620
+ }.get(error_code)
621
+ if mapped_error:
622
+ return mapped_error
623
+
624
+ warnings = result.get("warnings")
625
+ if isinstance(warnings, list):
626
+ for warning in warnings:
627
+ if isinstance(warning, dict) and warning.get("message"):
628
+ return str(warning.get("message"))
629
+
630
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
631
+ transport_error = data.get("transport_error") if isinstance(data.get("transport_error"), dict) else {}
632
+ backend_code = transport_error.get("backend_code")
633
+ http_status = transport_error.get("http_status")
634
+ if backend_code not in (None, ""):
635
+ return f"后端返回错误码 {backend_code}。"
636
+ if http_status not in (None, ""):
637
+ return f"请求返回 HTTP {http_status}。"
638
+
639
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
640
+ record_state_error = verification.get("record_state_error") if isinstance(verification.get("record_state_error"), dict) else {}
641
+ backend_code = record_state_error.get("backend_code")
642
+ http_status = record_state_error.get("http_status")
643
+ if backend_code not in (None, ""):
644
+ return f"后端返回错误码 {backend_code}。"
645
+ if http_status not in (None, ""):
646
+ return f"请求返回 HTTP {http_status}。"
647
+ if error_code:
648
+ return f"错误码:{error_code}"
649
+ return None
650
+
651
+
652
+ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
653
+ lines: list[str] = []
654
+ error_code = result.get("error_code")
655
+ if error_code not in (None, ""):
656
+ lines.append(f"error_code: {error_code}")
657
+
658
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
659
+ transport_error = data.get("transport_error") if isinstance(data.get("transport_error"), dict) else {}
660
+ for key in ("backend_code", "http_status", "category"):
661
+ value = transport_error.get(key)
662
+ if value not in (None, ""):
663
+ lines.append(f"{key}: {value}")
664
+
665
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
666
+ for key in (
667
+ "runtime_continuation_verified",
668
+ "task_context_visibility_verified",
669
+ "fields_saved_verified",
670
+ "task_still_actionable",
671
+ "workflow_not_advanced",
672
+ "record_state_readable",
673
+ ):
674
+ if key in verification and verification.get(key) is not None:
675
+ lines.append(f"{key}: {verification.get(key)}")
676
+
677
+ record_state_error = verification.get("record_state_error") if isinstance(verification.get("record_state_error"), dict) else {}
678
+ for key in ("backend_code", "http_status", "category"):
679
+ value = record_state_error.get(key)
680
+ if value not in (None, ""):
681
+ entry = f"record_state_{key}: {value}"
682
+ if entry not in lines:
683
+ lines.append(entry)
684
+ return lines
685
+
686
+
322
687
  _FORMATTERS = {
323
688
  "auth_whoami": _format_whoami,
324
689
  "workspace_list": _format_workspace_list,
690
+ "workspace_get": _format_workspace_get,
691
+ "workspace_select": _format_workspace_select,
325
692
  "app_list": _format_app_items,
326
693
  "app_search": _format_app_items,
327
694
  "app_get": _format_app_get,
328
695
  "record_list": _format_record_list,
329
696
  "task_list": _format_task_list,
697
+ "task_workbench": _format_task_workbench,
330
698
  "task_get": _format_task_get,
699
+ "task_action_execute": _format_task_action,
700
+ "task_associated_report_detail_get": _format_task_associated_report_detail,
331
701
  "import_verify": _format_import_verify,
332
702
  "import_status": _format_import_status,
703
+ "export_start": _format_export_start,
704
+ "export_status": _format_export_status,
705
+ "export_get": _format_export_get,
706
+ "export_direct": _format_export_direct,
333
707
  "builder_summary": _format_builder_summary,
334
708
  }
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable, Generic, TypeVar
5
+
6
+ from .terminal_ui import SelectionOption, select_option
7
+
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class InteractiveSelectionResult(Generic[T]):
14
+ status: str
15
+ value: T | None = None
16
+ message: str | None = None
17
+
18
+
19
+ def cancelled_result(message: str = "已取消") -> dict[str, str]:
20
+ return {"status": "cancelled", "message": message}
21
+
22
+
23
+ def resolve_interactive_selection(
24
+ args: object,
25
+ *,
26
+ title: str,
27
+ unavailable_message: str,
28
+ empty_message: str,
29
+ load_options: Callable[[], list[SelectionOption[T]]],
30
+ ) -> InteractiveSelectionResult[T]:
31
+ input_stream = getattr(args, "_stdin", None)
32
+ output_stream = getattr(args, "_stderr_stream", None)
33
+ if input_stream is None or output_stream is None:
34
+ return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
35
+ if not bool(getattr(input_stream, "isatty", lambda: False)()):
36
+ return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
37
+
38
+ options = list(load_options())
39
+ if not options:
40
+ return InteractiveSelectionResult(status="empty", message=empty_message)
41
+
42
+ value = select_option(
43
+ title=title,
44
+ options=options,
45
+ input_stream=input_stream,
46
+ output_stream=output_stream,
47
+ )
48
+ if value is None:
49
+ return InteractiveSelectionResult(status="cancelled", message="已取消")
50
+ return InteractiveSelectionResult(status="selected", value=value)
51
+
52
+
53
+ def resolve_interactive_text_input(
54
+ args: object,
55
+ *,
56
+ prompt: str,
57
+ unavailable_message: str,
58
+ ) -> InteractiveSelectionResult[str]:
59
+ input_stream = getattr(args, "_stdin", None)
60
+ output_stream = getattr(args, "_stderr_stream", None)
61
+ if input_stream is None or output_stream is None:
62
+ return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
63
+ if not bool(getattr(input_stream, "isatty", lambda: False)()):
64
+ return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
65
+
66
+ output_stream.write(prompt)
67
+ output_stream.flush()
68
+ line = input_stream.readline()
69
+ text = str(line or "").strip()
70
+ if not text:
71
+ return InteractiveSelectionResult(status="cancelled", message="已取消")
72
+ return InteractiveSelectionResult(status="selected", value=text)