@josephyan/qingflow-app-user-mcp 0.2.0-beta.1003 → 0.2.0-beta.1004
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.
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1004
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1004 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from ..context import CliContext
|
|
6
|
-
from ..interaction import cancelled_result, resolve_interactive_selection
|
|
7
|
+
from ..interaction import cancelled_result, resolve_interactive_selection, resolve_interactive_text_input
|
|
7
8
|
from ..terminal_ui import SelectionOption
|
|
8
9
|
from .common import load_object_arg, raise_config_error
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
TASK_ACTION_LABELS = {
|
|
13
|
+
"approve": "通过",
|
|
14
|
+
"reject": "驳回",
|
|
15
|
+
"rollback": "退回",
|
|
16
|
+
"transfer": "转交",
|
|
17
|
+
"save_only": "仅保存",
|
|
18
|
+
"urge": "催办",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
11
22
|
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
12
23
|
parser = subparsers.add_parser("task", help="待办与流程上下文")
|
|
13
24
|
task_subparsers = parser.add_subparsers(dest="task_command", required=True)
|
|
@@ -39,7 +50,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
39
50
|
action.add_argument("--app-key")
|
|
40
51
|
action.add_argument("--record-id")
|
|
41
52
|
action.add_argument("--workflow-node-id", type=int)
|
|
42
|
-
action.add_argument("--action",
|
|
53
|
+
action.add_argument("--action", help="不传时在交互终端中选择当前待办可执行动作")
|
|
43
54
|
action.add_argument("--payload-file")
|
|
44
55
|
action.add_argument("--fields-file")
|
|
45
56
|
action.set_defaults(
|
|
@@ -95,10 +106,21 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
95
106
|
|
|
96
107
|
|
|
97
108
|
def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
selection_result = _resolve_task_locator_or_select(args, context, tool_name="task action")
|
|
110
|
+
if isinstance(selection_result, dict):
|
|
111
|
+
return selection_result
|
|
112
|
+
payload = load_object_arg(args.payload_file, option_name="--payload-file") or {}
|
|
113
|
+
fields = load_object_arg(args.fields_file, option_name="--fields-file") or {}
|
|
114
|
+
task_context: dict[str, Any] | None = None
|
|
115
|
+
if not str(args.action or "").strip():
|
|
116
|
+
task_context = _load_task_action_context(args, context)
|
|
117
|
+
action_selection = _resolve_task_action_or_select(args, task_context, fields=fields)
|
|
118
|
+
if isinstance(action_selection, dict):
|
|
119
|
+
return action_selection
|
|
120
|
+
payload_selection = _resolve_action_payload_or_select(args, task_context, payload=payload)
|
|
121
|
+
if _is_cancelled_result(payload_selection):
|
|
122
|
+
return payload_selection
|
|
123
|
+
payload = payload_selection
|
|
102
124
|
return context.task.task_action_execute(
|
|
103
125
|
profile=args.profile,
|
|
104
126
|
task_id=args.task_id,
|
|
@@ -106,8 +128,8 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
106
128
|
record_id=args.record_id or "",
|
|
107
129
|
workflow_node_id=int(args.workflow_node_id or 0),
|
|
108
130
|
action=args.action,
|
|
109
|
-
payload=
|
|
110
|
-
fields=
|
|
131
|
+
payload=payload,
|
|
132
|
+
fields=fields,
|
|
111
133
|
)
|
|
112
134
|
|
|
113
135
|
|
|
@@ -253,6 +275,244 @@ def _resolve_report_id_or_select(args: argparse.Namespace, context: CliContext)
|
|
|
253
275
|
return None
|
|
254
276
|
|
|
255
277
|
|
|
278
|
+
def _load_task_action_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
|
|
279
|
+
return context.task.task_get(
|
|
280
|
+
profile=args.profile,
|
|
281
|
+
task_id=args.task_id,
|
|
282
|
+
app_key=args.app_key or "",
|
|
283
|
+
record_id=args.record_id or "",
|
|
284
|
+
workflow_node_id=int(args.workflow_node_id or 0),
|
|
285
|
+
include_candidates=True,
|
|
286
|
+
include_associated_reports=False,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _resolve_task_action_or_select(
|
|
291
|
+
args: argparse.Namespace,
|
|
292
|
+
task_context: dict[str, Any],
|
|
293
|
+
*,
|
|
294
|
+
fields: dict[str, Any],
|
|
295
|
+
) -> dict | None:
|
|
296
|
+
selection = _choose_task_action_interactively(args, task_context, fields=fields)
|
|
297
|
+
if selection.status == "unavailable":
|
|
298
|
+
raise_config_error(
|
|
299
|
+
"task action requires --action, or an interactive terminal to choose the current task action",
|
|
300
|
+
fix_hint="Pass `--action ACTION`, or run `qingflow task action` in an interactive terminal and choose from available actions.",
|
|
301
|
+
)
|
|
302
|
+
if selection.status == "empty":
|
|
303
|
+
raise_config_error(
|
|
304
|
+
selection.message or "task action could not open an action selector because no interactive actions are available.",
|
|
305
|
+
fix_hint="Run `task get --task-id TASK_ID` to inspect available_actions, or pass a supported `--action` explicitly.",
|
|
306
|
+
)
|
|
307
|
+
if selection.status == "cancelled":
|
|
308
|
+
return cancelled_result(selection.message or "已取消")
|
|
309
|
+
|
|
310
|
+
args.action = str(selection.value or "").strip()
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _choose_task_action_interactively(
|
|
315
|
+
args: argparse.Namespace,
|
|
316
|
+
task_context: dict[str, Any],
|
|
317
|
+
*,
|
|
318
|
+
fields: dict[str, Any],
|
|
319
|
+
):
|
|
320
|
+
data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
|
|
321
|
+
available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
|
|
322
|
+
extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
|
|
323
|
+
action_metadata = data.get("action_metadata") if isinstance(data.get("action_metadata"), dict) else {}
|
|
324
|
+
feedback_required = {
|
|
325
|
+
str(item).strip()
|
|
326
|
+
for item in (action_metadata.get("feedback_required_for") or [])
|
|
327
|
+
if str(item).strip()
|
|
328
|
+
}
|
|
329
|
+
rollback_count = _count_candidate_items(extras.get("rollback_candidates"))
|
|
330
|
+
transfer_count = _count_candidate_items(extras.get("transfer_candidates"))
|
|
331
|
+
|
|
332
|
+
def load_options() -> list[SelectionOption[str]]:
|
|
333
|
+
options: list[SelectionOption[str]] = []
|
|
334
|
+
for action in available_actions:
|
|
335
|
+
if action == "save_only" and not fields:
|
|
336
|
+
continue
|
|
337
|
+
label = TASK_ACTION_LABELS.get(action, action)
|
|
338
|
+
hint_parts = [action]
|
|
339
|
+
if action in feedback_required:
|
|
340
|
+
hint_parts.append("需要理由")
|
|
341
|
+
if action == "rollback":
|
|
342
|
+
hint_parts.append(f"可退回节点 {rollback_count}")
|
|
343
|
+
if action == "transfer":
|
|
344
|
+
hint_parts.append(f"可转交成员 {transfer_count}")
|
|
345
|
+
if action == "save_only":
|
|
346
|
+
hint_parts.append("仅保存字段,不推进流程")
|
|
347
|
+
options.append(SelectionOption(value=action, label=label, hint=" · ".join(hint_parts)))
|
|
348
|
+
return options
|
|
349
|
+
|
|
350
|
+
return resolve_interactive_selection(
|
|
351
|
+
args,
|
|
352
|
+
title="选择操作",
|
|
353
|
+
unavailable_message="task action requires --action, or an interactive terminal to choose the current task action",
|
|
354
|
+
empty_message="task action could not open an action selector because no interactive actions are available.",
|
|
355
|
+
load_options=load_options,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _resolve_action_payload_or_select(
|
|
360
|
+
args: argparse.Namespace,
|
|
361
|
+
task_context: dict[str, Any],
|
|
362
|
+
*,
|
|
363
|
+
payload: dict[str, Any],
|
|
364
|
+
) -> dict[str, Any] | dict[str, str]:
|
|
365
|
+
action = str(args.action or "").strip().lower()
|
|
366
|
+
if action == "reject" and not _extract_audit_feedback(payload):
|
|
367
|
+
feedback = resolve_interactive_text_input(
|
|
368
|
+
args,
|
|
369
|
+
prompt="请输入驳回原因并回车,留空取消: ",
|
|
370
|
+
unavailable_message="task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
|
|
371
|
+
)
|
|
372
|
+
if feedback.status == "unavailable":
|
|
373
|
+
raise_config_error(
|
|
374
|
+
"task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
|
|
375
|
+
fix_hint="Pass `--payload-file` with `{\"audit_feedback\": \"...\"}`, or retry in an interactive terminal.",
|
|
376
|
+
)
|
|
377
|
+
if feedback.status == "cancelled":
|
|
378
|
+
return cancelled_result(feedback.message or "已取消")
|
|
379
|
+
payload["audit_feedback"] = str(feedback.value or "").strip()
|
|
380
|
+
return payload
|
|
381
|
+
|
|
382
|
+
if action == "rollback" and _extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId")) <= 0:
|
|
383
|
+
selection = _choose_rollback_candidate_interactively(args, task_context)
|
|
384
|
+
if selection.status == "unavailable":
|
|
385
|
+
raise_config_error(
|
|
386
|
+
"task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
|
|
387
|
+
fix_hint="Pass `--payload-file` with `{\"target_workflow_node_id\": NODE_ID}`, or retry in an interactive terminal.",
|
|
388
|
+
)
|
|
389
|
+
if selection.status == "empty":
|
|
390
|
+
raise_config_error(
|
|
391
|
+
selection.message or "task rollback could not open a selector because no rollback candidates are visible.",
|
|
392
|
+
fix_hint="Run `task get --task-id TASK_ID` to inspect rollback candidates, or choose another action.",
|
|
393
|
+
)
|
|
394
|
+
if selection.status == "cancelled":
|
|
395
|
+
return cancelled_result(selection.message or "已取消")
|
|
396
|
+
payload["target_workflow_node_id"] = int(selection.value or 0)
|
|
397
|
+
return payload
|
|
398
|
+
|
|
399
|
+
if action == "transfer" and _extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId")) <= 0:
|
|
400
|
+
selection = _choose_transfer_candidate_interactively(args, task_context)
|
|
401
|
+
if selection.status == "unavailable":
|
|
402
|
+
raise_config_error(
|
|
403
|
+
"task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
|
|
404
|
+
fix_hint="Pass `--payload-file` with `{\"target_member_id\": UID}`, or retry in an interactive terminal.",
|
|
405
|
+
)
|
|
406
|
+
if selection.status == "empty":
|
|
407
|
+
raise_config_error(
|
|
408
|
+
selection.message or "task transfer could not open a selector because no transfer candidates are visible.",
|
|
409
|
+
fix_hint="Run `task get --task-id TASK_ID` to inspect transfer candidates, or choose another action.",
|
|
410
|
+
)
|
|
411
|
+
if selection.status == "cancelled":
|
|
412
|
+
return cancelled_result(selection.message or "已取消")
|
|
413
|
+
payload["target_member_id"] = int(selection.value or 0)
|
|
414
|
+
return payload
|
|
415
|
+
|
|
416
|
+
return payload
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _choose_rollback_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
|
|
420
|
+
extras = _task_context_extras(task_context)
|
|
421
|
+
|
|
422
|
+
def load_options() -> list[SelectionOption[int]]:
|
|
423
|
+
rollback_candidates = extras.get("rollback_candidates") if isinstance(extras.get("rollback_candidates"), dict) else {}
|
|
424
|
+
items = rollback_candidates.get("items") if isinstance(rollback_candidates.get("items"), list) else []
|
|
425
|
+
options: list[SelectionOption[int]] = []
|
|
426
|
+
for item in items:
|
|
427
|
+
if not isinstance(item, dict):
|
|
428
|
+
continue
|
|
429
|
+
workflow_node_id = int(item.get("workflow_node_id") or 0)
|
|
430
|
+
if workflow_node_id <= 0:
|
|
431
|
+
continue
|
|
432
|
+
workflow_node_name = str(item.get("workflow_node_name") or f"节点 {workflow_node_id}").strip() or f"节点 {workflow_node_id}"
|
|
433
|
+
options.append(
|
|
434
|
+
SelectionOption(
|
|
435
|
+
value=workflow_node_id,
|
|
436
|
+
label=workflow_node_name,
|
|
437
|
+
hint=f"workflow_node_id={workflow_node_id}",
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
return options
|
|
441
|
+
|
|
442
|
+
return resolve_interactive_selection(
|
|
443
|
+
args,
|
|
444
|
+
title="选择退回节点",
|
|
445
|
+
unavailable_message="task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
|
|
446
|
+
empty_message="task rollback could not open a selector because no rollback candidates are visible.",
|
|
447
|
+
load_options=load_options,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _choose_transfer_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
|
|
452
|
+
extras = _task_context_extras(task_context)
|
|
453
|
+
|
|
454
|
+
def load_options() -> list[SelectionOption[int]]:
|
|
455
|
+
transfer_candidates = extras.get("transfer_candidates") if isinstance(extras.get("transfer_candidates"), dict) else {}
|
|
456
|
+
items = transfer_candidates.get("items") if isinstance(transfer_candidates.get("items"), list) else []
|
|
457
|
+
options: list[SelectionOption[int]] = []
|
|
458
|
+
for item in items:
|
|
459
|
+
if not isinstance(item, dict):
|
|
460
|
+
continue
|
|
461
|
+
uid = int(item.get("uid") or 0)
|
|
462
|
+
if uid <= 0:
|
|
463
|
+
continue
|
|
464
|
+
name = str(item.get("name") or f"成员 {uid}").strip() or f"成员 {uid}"
|
|
465
|
+
hint_parts = [f"uid={uid}"]
|
|
466
|
+
department_name = str(item.get("department_name") or "").strip()
|
|
467
|
+
email = str(item.get("email") or "").strip()
|
|
468
|
+
if department_name:
|
|
469
|
+
hint_parts.append(department_name)
|
|
470
|
+
if email:
|
|
471
|
+
hint_parts.append(email)
|
|
472
|
+
options.append(SelectionOption(value=uid, label=name, hint=" · ".join(hint_parts)))
|
|
473
|
+
return options
|
|
474
|
+
|
|
475
|
+
return resolve_interactive_selection(
|
|
476
|
+
args,
|
|
477
|
+
title="选择转交成员",
|
|
478
|
+
unavailable_message="task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
|
|
479
|
+
empty_message="task transfer could not open a selector because no transfer candidates are visible.",
|
|
480
|
+
load_options=load_options,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _task_context_extras(task_context: dict[str, Any]) -> dict[str, Any]:
|
|
485
|
+
data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
|
|
486
|
+
return data.get("extras") if isinstance(data.get("extras"), dict) else {}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _count_candidate_items(value: Any) -> int:
|
|
490
|
+
if not isinstance(value, dict):
|
|
491
|
+
return 0
|
|
492
|
+
items = value.get("items")
|
|
493
|
+
return len(items) if isinstance(items, list) else 0
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _extract_audit_feedback(payload: dict[str, Any]) -> str | None:
|
|
497
|
+
for key in ("audit_feedback", "auditFeedback"):
|
|
498
|
+
value = payload.get(key)
|
|
499
|
+
if isinstance(value, str) and value.strip():
|
|
500
|
+
return value.strip()
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _extract_positive_int(payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
|
|
505
|
+
for candidate in (key, *aliases):
|
|
506
|
+
value = payload.get(candidate)
|
|
507
|
+
if isinstance(value, int) and value > 0:
|
|
508
|
+
return value
|
|
509
|
+
return 0
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _is_cancelled_result(value: Any) -> bool:
|
|
513
|
+
return isinstance(value, dict) and str(value.get("status") or "").strip().lower() == "cancelled"
|
|
514
|
+
|
|
515
|
+
|
|
256
516
|
def _choose_associated_report_interactively(args: argparse.Namespace, context: CliContext):
|
|
257
517
|
def load_options() -> list[SelectionOption[int]]:
|
|
258
518
|
result = context.task.task_get(
|
|
@@ -48,3 +48,25 @@ def resolve_interactive_selection(
|
|
|
48
48
|
if value is None:
|
|
49
49
|
return InteractiveSelectionResult(status="cancelled", message="已取消")
|
|
50
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)
|