weaver-work-cli 0.1.5 → 0.1.7

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 (225) hide show
  1. package/README.md +14 -9
  2. package/dist/cmd/skills/index.js +60 -0
  3. package/dist/internal/e10/auth/commands.js +48 -32
  4. package/dist/internal/e10/auth/session.js +44 -3
  5. package/dist/internal/e10/auth/xiaoe.js +363 -11
  6. package/dist/internal/e10/context.js +4 -2
  7. package/dist/internal/skills/detector.js +42 -0
  8. package/dist/internal/skills/install.js +50 -3
  9. package/dist/shortcuts/archive/render/search-format.js +12 -3
  10. package/dist/shortcuts/ebuilder-form/continuation.js +70 -0
  11. package/dist/shortcuts/ebuilder-form/errors.js +54 -0
  12. package/dist/shortcuts/ebuilder-form/host.js +1238 -0
  13. package/dist/shortcuts/ebuilder-form/index.js +108 -0
  14. package/dist/shortcuts/ebuilder-form/manifest.js +347 -0
  15. package/dist/shortcuts/ebuilder-form/operations/read.js +157 -0
  16. package/dist/shortcuts/ebuilder-form/operations/registry.js +56 -0
  17. package/dist/shortcuts/ebuilder-form/operations/shared.js +132 -0
  18. package/dist/shortcuts/ebuilder-form/operations/types.js +19 -0
  19. package/dist/shortcuts/ebuilder-form/operations/write.js +277 -0
  20. package/dist/shortcuts/ebuilder-form/operations.js +8 -0
  21. package/dist/shortcuts/ehr/operations/salary.js +2 -140
  22. package/dist/shortcuts/im/continuation.js +70 -0
  23. package/dist/shortcuts/{yimiaoban → im}/errors.js +14 -14
  24. package/dist/shortcuts/{yimiaoban → im}/host.js +26 -26
  25. package/dist/shortcuts/{yimiaoban → im}/index.js +42 -42
  26. package/dist/shortcuts/{yimiaoban → im}/manifest.js +7 -6
  27. package/dist/shortcuts/{yimiaoban → im}/operations/file-user.js +54 -41
  28. package/dist/shortcuts/{yimiaoban → im}/operations/group-read.js +46 -44
  29. package/dist/shortcuts/{yimiaoban → im}/operations/group-write.js +113 -66
  30. package/dist/shortcuts/{yimiaoban → im}/operations/msg-sync.js +164 -141
  31. package/dist/shortcuts/{yimiaoban → im}/operations/msg-write.js +47 -47
  32. package/dist/shortcuts/{yimiaoban → im}/operations/person.js +10 -10
  33. package/dist/shortcuts/{yimiaoban → im}/operations/registry.js +10 -8
  34. package/dist/shortcuts/{yimiaoban → im}/operations/session.js +82 -83
  35. package/dist/shortcuts/im/operations/shared.js +442 -0
  36. package/dist/shortcuts/{yimiaoban → im}/operations/types.js +1 -1
  37. package/dist/shortcuts/im/operations/user-remind.js +201 -0
  38. package/dist/shortcuts/{yimiaoban → im}/operations/write-util.js +7 -7
  39. package/dist/shortcuts/{yimiaoban → im}/operations.js +3 -3
  40. package/dist/shortcuts/index.js +10 -2
  41. package/dist/shortcuts/invoice/manifest.js +113 -10
  42. package/dist/shortcuts/invoice/operations/import.js +39 -14
  43. package/dist/shortcuts/invoice/operations/reim.js +38 -18
  44. package/dist/shortcuts/invoice/operations/shared.js +35 -6
  45. package/dist/shortcuts/jiuchuanhui/manifest.js +3 -3
  46. package/dist/shortcuts/jiuchuanhui/operations/registry.js +2 -2
  47. package/dist/shortcuts/{yimiaoban → okr}/continuation.js +10 -10
  48. package/dist/shortcuts/okr/errors.js +70 -0
  49. package/dist/shortcuts/okr/host.js +169 -0
  50. package/dist/shortcuts/okr/index.js +155 -0
  51. package/dist/shortcuts/okr/manifest.js +70 -0
  52. package/dist/shortcuts/okr/operations/link.js +305 -0
  53. package/dist/shortcuts/okr/operations/read.js +481 -0
  54. package/dist/shortcuts/okr/operations/registry.js +31 -0
  55. package/dist/shortcuts/okr/operations/shared.js +381 -0
  56. package/dist/shortcuts/okr/operations/types.js +88 -0
  57. package/dist/shortcuts/okr/operations/write.js +838 -0
  58. package/dist/shortcuts/okr/operations.js +8 -0
  59. package/dist/shortcuts/project/continuation.js +70 -0
  60. package/dist/shortcuts/project/errors.js +53 -0
  61. package/dist/shortcuts/project/host.js +160 -0
  62. package/dist/shortcuts/project/index.js +106 -0
  63. package/dist/shortcuts/project/manifest.js +36 -0
  64. package/dist/shortcuts/project/operations/read.js +551 -0
  65. package/dist/shortcuts/project/operations/registry.js +31 -0
  66. package/dist/shortcuts/project/operations/shared.js +68 -0
  67. package/dist/shortcuts/project/operations/types.js +39 -0
  68. package/dist/shortcuts/project/operations/write.js +266 -0
  69. package/dist/shortcuts/project/operations.js +8 -0
  70. package/dist/shortcuts/workflow/continuation.js +508 -0
  71. package/dist/shortcuts/workflow/endpoints.js +149 -0
  72. package/dist/shortcuts/workflow/errors.js +112 -0
  73. package/dist/shortcuts/workflow/host.js +224 -0
  74. package/dist/shortcuts/workflow/index.js +116 -0
  75. package/dist/shortcuts/workflow/manifest.js +30 -0
  76. package/dist/shortcuts/workflow/operations/form.js +4159 -0
  77. package/dist/shortcuts/workflow/operations/read.js +3778 -0
  78. package/dist/shortcuts/workflow/operations/registry.js +33 -0
  79. package/dist/shortcuts/workflow/operations/shared.js +167 -0
  80. package/dist/shortcuts/workflow/operations/types.js +44 -0
  81. package/dist/shortcuts/workflow/operations/write.js +1991 -0
  82. package/dist/shortcuts/workflow/operations.js +119 -0
  83. package/dist/shortcuts/workflow/state.js +182 -0
  84. package/docs/SKILL.md +5 -3
  85. package/docs/_catalog.md +10 -1
  86. package/docs/agent-invoice.md +2 -2
  87. package/docs/agent-skill-install.md +2 -2
  88. package/docs/e10-auth.md +3 -1
  89. package/docs/ebuilder-form.md +38 -0
  90. package/docs/im.md +104 -0
  91. package/docs/invoice.md +5 -5
  92. package/docs/okr.md +122 -0
  93. package/docs/operation-manual.md +10 -7
  94. package/docs/project.md +69 -0
  95. package/docs/skill-review-2026-09-19.md +68 -0
  96. package/package.json +3 -2
  97. package/scripts/package-skill.mjs +38 -11
  98. package/scripts/sync-connector-skills.mjs +172 -0
  99. package/skill-template/business-info.json +357 -16
  100. package/skill-template/domains/ebuilder-form.md +5 -0
  101. package/skill-template/domains/okr.md +18 -0
  102. package/skill-template/domains/shijingran.md +3 -0
  103. package/skill-template/domains/skill-maker.md +4 -4
  104. package/skill-template/domains/workflow.md +22 -0
  105. package/skill-template/domains/yepiaotong.md +4 -2
  106. package/skill-template/domains/yimiaoban.md +13 -10
  107. package/skill-template/product.json +5 -0
  108. package/skill-template/skill-template.md +1 -0
  109. package/skills/weaver-e10-calendar/SKILL.md +5 -1
  110. package/skills/weaver-e10-calendar/references/calendar-query.md +7 -0
  111. package/skills/weaver-e10-calendar/references/source-manifest.json +314 -32
  112. package/skills/weaver-e10-ebuilder-form/SKILL.md +123 -0
  113. package/skills/weaver-e10-ebuilder-form/references/context-routing.md +90 -0
  114. package/skills/weaver-e10-ebuilder-form/references/custom-api.md +86 -0
  115. package/skills/weaver-e10-ebuilder-form/references/disabled-capabilities.md +50 -0
  116. package/skills/weaver-e10-ebuilder-form/references/fields-browser.md +100 -0
  117. package/skills/weaver-e10-ebuilder-form/references/list-nlist.md +91 -0
  118. package/skills/weaver-e10-ebuilder-form/references/openapi-read.md +74 -0
  119. package/skills/weaver-e10-ebuilder-form/references/openapi-write.md +86 -0
  120. package/skills/weaver-e10-ebuilder-form/references/source-manifest.json +107 -0
  121. package/skills/weaver-e10-esb/SKILL.md +1 -1
  122. package/skills/weaver-e10-esb/references/source-manifest.json +15 -14
  123. package/skills/weaver-e10-hrm/SKILL.md +1 -1
  124. package/skills/weaver-e10-hrm/references/source-manifest.json +357 -38
  125. package/skills/weaver-e10-jiuchuanhui/SKILL.md +22 -4
  126. package/skills/weaver-e10-jiuchuanhui/references/clue.md +4 -2
  127. package/skills/weaver-e10-jiuchuanhui/references/contact.md +1 -1
  128. package/skills/weaver-e10-jiuchuanhui/references/customer.md +3 -3
  129. package/skills/weaver-e10-jiuchuanhui/references/disabled-capabilities.md +1 -1
  130. package/skills/weaver-e10-jiuchuanhui/references/discovery-config.md +10 -10
  131. package/skills/weaver-e10-jiuchuanhui/references/general-helpers.md +3 -3
  132. package/skills/weaver-e10-jiuchuanhui/references/sale.md +7 -7
  133. package/skills/weaver-e10-jiuchuanhui/references/source-manifest.json +3 -3
  134. package/skills/weaver-e10-jucailin/SKILL.md +2 -2
  135. package/skills/weaver-e10-mail/SKILL.md +22 -6
  136. package/skills/weaver-e10-meeting/SKILL.md +22 -1
  137. package/skills/weaver-e10-okr/SKILL.md +139 -0
  138. package/skills/weaver-e10-okr/references/okr-align.md +59 -0
  139. package/skills/weaver-e10-okr/references/okr-keyresult.md +68 -0
  140. package/skills/weaver-e10-okr/references/okr-link-routing.md +67 -0
  141. package/skills/weaver-e10-okr/references/okr-query.md +105 -0
  142. package/skills/weaver-e10-okr/references/okr-write.md +94 -0
  143. package/skills/weaver-e10-okr/references/source-manifest.json +672 -0
  144. package/skills/weaver-e10-plan/SKILL.md +3 -3
  145. package/skills/weaver-e10-plan/references/plan-report-write.md +1 -1
  146. package/skills/weaver-e10-plan/references/source-manifest.json +20 -20
  147. package/skills/weaver-e10-qiyecheng/SKILL.md +21 -1
  148. package/skills/weaver-e10-qiyecheng/references/source-manifest.json +17 -17
  149. package/skills/weaver-e10-shared/SKILL.md +79 -11
  150. package/skills/weaver-e10-shared/references/e10-auth-and-session.md +41 -8
  151. package/skills/weaver-e10-shared/references/non-interactive-environment.md +78 -0
  152. package/skills/weaver-e10-shared/references/weaver-e10-installation.md +39 -0
  153. package/skills/weaver-e10-shijingran/SKILL.md +83 -0
  154. package/skills/weaver-e10-shijingran/references/field-resolution.md +105 -0
  155. package/skills/weaver-e10-shijingran/references/operations.md +154 -0
  156. package/skills/weaver-e10-shijingran/references/source-manifest.json +1380 -0
  157. package/skills/weaver-e10-shijingran/references/write-operations.md +90 -0
  158. package/skills/weaver-e10-skill-maker/SKILL.md +26 -9
  159. package/skills/weaver-e10-skill-maker/references/business-skill-generation.md +39 -8
  160. package/skills/weaver-e10-skill-maker/references/detector-validation.md +25 -8
  161. package/skills/weaver-e10-skill-maker/references/module-cli-generation.md +66 -5
  162. package/skills/weaver-e10-skill-maker/references/module-gap-audit-and-repair.md +86 -0
  163. package/skills/weaver-e10-skill-maker/references/source-update-detection.md +59 -5
  164. package/skills/weaver-e10-skill-maker/references/weaver-skill-style.md +16 -8
  165. package/skills/weaver-e10-wenshuding/SKILL.md +28 -4
  166. package/skills/weaver-e10-wenshuding/references/archive-search.md +14 -0
  167. package/skills/weaver-e10-wenshuding/references/source-manifest.json +27 -26
  168. package/skills/weaver-e10-workflow/SKILL.md +250 -0
  169. package/skills/weaver-e10-workflow/references/source-manifest.json +1062 -0
  170. package/skills/weaver-e10-workflow/references/workflow-agent-entry.md +96 -0
  171. package/skills/weaver-e10-workflow/references/workflow-batch.md +275 -0
  172. package/skills/weaver-e10-workflow/references/workflow-create.md +182 -0
  173. package/skills/weaver-e10-workflow/references/workflow-edit.md +106 -0
  174. package/skills/weaver-e10-workflow/references/workflow-memory.md +165 -0
  175. package/skills/weaver-e10-workflow/references/workflow-operate.md +153 -0
  176. package/skills/weaver-e10-workflow/references/workflow-query.md +821 -0
  177. package/skills/weaver-e10-workflow/references/workflow-share.md +117 -0
  178. package/skills/weaver-e10-workflow/references/workflow-view.md +189 -0
  179. package/skills/weaver-e10-yepiaotong/SKILL.md +28 -28
  180. package/skills/weaver-e10-yepiaotong/references/invoice-disabled-capabilities.md +11 -5
  181. package/skills/weaver-e10-yepiaotong/references/invoice-import.md +14 -8
  182. package/skills/weaver-e10-yepiaotong/references/invoice-issuing-examples.md +9 -43
  183. package/skills/weaver-e10-yepiaotong/references/invoice-issuing.md +8 -0
  184. package/skills/weaver-e10-yepiaotong/references/invoice-reim.md +13 -17
  185. package/skills/weaver-e10-yepiaotong/references/reim-api-reference.md +6 -7
  186. package/skills/weaver-e10-yepiaotong/references/reim-examples.md +2 -2
  187. package/skills/weaver-e10-yepiaotong/references/source-manifest.json +1155 -553
  188. package/skills/weaver-e10-yimiaoban/SKILL.md +56 -38
  189. package/skills/weaver-e10-yimiaoban/references/ding-write.md +8 -8
  190. package/skills/weaver-e10-yimiaoban/references/error-codes.md +16 -0
  191. package/skills/weaver-e10-yimiaoban/references/field-resolution.md +10 -10
  192. package/skills/weaver-e10-yimiaoban/references/file-user-i18n.md +12 -12
  193. package/skills/weaver-e10-yimiaoban/references/group-read.md +16 -16
  194. package/skills/weaver-e10-yimiaoban/references/group-write.md +17 -15
  195. package/skills/weaver-e10-yimiaoban/references/msg-read.md +21 -17
  196. package/skills/weaver-e10-yimiaoban/references/msg-write.md +10 -10
  197. package/skills/weaver-e10-yimiaoban/references/person.md +6 -6
  198. package/skills/weaver-e10-yimiaoban/references/safety-boundaries.md +15 -11
  199. package/skills/weaver-e10-yimiaoban/references/session-sysmsg.md +11 -10
  200. package/skills/weaver-e10-yimiaoban/references/source-manifest.json +2164 -154
  201. package/skills/weaver-e10-yimiaoban/references/user-remind.md +75 -0
  202. package/skills/weaver-e10-ziguanjia/SKILL.md +4 -2
  203. package/skills/weaver-e10-ziguanjia/references/source-manifest.json +99 -29
  204. package/tools/weaver-skill-detector-1.0.13/SKILL.md +253 -0
  205. package/tools/weaver-skill-detector-1.0.13/_meta.json +6 -0
  206. package/tools/weaver-skill-detector-1.0.13/references/checklist.md +298 -0
  207. package/tools/weaver-skill-detector-1.0.13/scripts/validate_skill.py +4190 -0
  208. package/dist/shortcuts/yimiaoban/operations/shared.js +0 -236
  209. package/docs/yimiaoban.md +0 -86
  210. package/skills/weaver-e10-calendar/product.json +0 -8
  211. package/skills/weaver-e10-esb/product.json +0 -8
  212. package/skills/weaver-e10-hrm/product.json +0 -8
  213. package/skills/weaver-e10-jiuchuanhui/product.json +0 -8
  214. package/skills/weaver-e10-jucailin/product.json +0 -8
  215. package/skills/weaver-e10-mail/product.json +0 -8
  216. package/skills/weaver-e10-meeting/product.json +0 -8
  217. package/skills/weaver-e10-plan/product.json +0 -8
  218. package/skills/weaver-e10-qiyecheng/product.json +0 -8
  219. package/skills/weaver-e10-skill-maker/product.json +0 -8
  220. package/skills/weaver-e10-wenshuding/product.json +0 -8
  221. package/skills/weaver-e10-yepiaotong/product.json +0 -8
  222. package/skills/weaver-e10-yepiaotong/references/invoice-red.md +0 -261
  223. package/skills/weaver-e10-yimiaoban/product.json +0 -8
  224. package/skills/weaver-e10-ziguanjia/product.json +0 -8
  225. /package/dist/shortcuts/{yimiaoban → im}/operations/msg-parse.js +0 -0
@@ -0,0 +1,3778 @@
1
+ // E10 工作流「查询/查看」类 operation 集合。
2
+ //
3
+ // 移植自 Python skill `scripts/e10_workflow.py`,唯一事实来源,严禁臆造接口/字段。
4
+ // 仅实现 8 个面向模型的高层 operation;query-dependency / relate-name2id / raw-* 等
5
+ // 内部步骤内联进对应 operation,不单独暴露。
6
+ //
7
+ // 顺序定位约定:查询类 operation 把"序号 → requestId"写入本地检索索引
8
+ // (saveSearchIndex / findIndexedItem,由 CLI 托管,本文件不读写索引文件);
9
+ // 查看/操作类 operation 允许用 requestId 或 group+index 定位(findIndexedItem)。
10
+ import { workflowSuccess, schemaOf, SCHEMA_REQUEST_ID, SCHEMA_CATEGORY, SCHEMA_INDEX, SCHEMA_PAGE_SIZE, } from './types.js';
11
+ import { asString, asInt, asStringArray, asBool, compact, responseData, toArray, resolveMemoryAppId, withLeadingMeta, STDOUT_SAFE_BYTES, stdoutBytesOf, } from './shared.js';
12
+ import { workflowValidationError, WorkflowCliError, workflowClarificationError, CLARIFICATION_SUBTYPE, } from '../errors.js';
13
+ import { WORKFLOW_ENDPOINTS } from '../endpoints.js';
14
+ import { loadSearchIndex, saveSearchIndex, findIndexedItem, readStateFile, writeStateFileBestEffort, } from '../state.js';
15
+ const ENDPOINT_PREFIX = {
16
+ todo: 'TODO',
17
+ done: 'DONE',
18
+ mine: 'MINE',
19
+ all: 'ALL',
20
+ };
21
+ const DEFAULT_TABID = {
22
+ todo: '0',
23
+ done: '50',
24
+ mine: '100',
25
+ all: '300',
26
+ };
27
+ // 子分类别名 → tabid(来源:WORKFLOW_CATEGORIES.tabs)
28
+ const SUB_CATEGORY_TABS = {
29
+ todo: {
30
+ 全部: '0', 待处理: '7', 未读: '1', 新反馈: '2', 已查阅: '3', 待阅: '5', 超时: '6',
31
+ 被退回: '8', 转发: '4', 传阅: '11', 抄送: '9', 关注: '10', 被督办: '12', 被转办: '13', 草稿: '14',
32
+ },
33
+ done: {
34
+ 全部: '50', 审批中: '52', 已结束: '51', 待回复: '60', 未读: '61', 已处理: '53', 新反馈: '62',
35
+ 已阅: '54', 关注: '63', 抄送: '55', 转发: '56', 传阅: '57', 超时已办: '58', 超时自动处理: '59',
36
+ 已审批: '64', 待退回: '65', 暂停: '66',
37
+ },
38
+ mine: {
39
+ 全部: '100', 审批中: '102', 新反馈: '101', 已结束: '103', 未读: '104', 关注: '105', 暂停: '120',
40
+ },
41
+ all: { 全部: '300', 审批中: '303', 已结束: '304' },
42
+ };
43
+ const CATEGORY_NAMES = {
44
+ todo: '待办',
45
+ done: '已办',
46
+ mine: '我发起的',
47
+ all: '全部流程',
48
+ };
49
+ // 排序:排序方式名 → 接口 orderkey(来源:Python WORKFLOW_SORT_OPTIONS,L590,穷举全量)。
50
+ // 仅中文键名(Python 无英文别名);sortType 必须显式 asc/desc(无默认方向),与 Python parse_sort_params 一致。
51
+ const SORT_OPTIONS = {
52
+ 当前节点: -45, 发起人: -15, 发起时间: -20, 所属工作流: -40, 接收时间: -25,
53
+ 最后反馈时间: -175, 紧急程度: -55, 流程编号: -70, 流程标题: -10, 操作时间: -30, 结束时间: -35,
54
+ };
55
+ // plan 批量编排:特殊组槽位 tabid(来源:Python _PLAN_SLOT_TABID,L12882)
56
+ const PLAN_SLOT_TABID = { overtime: '6', urgent: '0', forward: '4' };
57
+ const PLAN_MAX_GROUPS = 8;
58
+ // plan 支持的特殊组类型(来源:Python _PLAN_SPECIAL_GROUP_TYPES,L12883)——传未知类型必须报错,不能静默丢弃
59
+ const PLAN_SPECIAL_GROUP_TYPES = ['urgentOvertime', 'forwardToSelf'];
60
+ // 展开组草稿口径(来源:Python _PLAN_GROUP_DRAFT_LABELS)
61
+ const PLAN_GROUP_DRAFT_LABELS = new Set(['草稿', '待提交']);
62
+ // todo-stat 写死的 attentionFormFields(来源:Python cmd_workflow_todo_stat,L11780)
63
+ const STAT_ATTENTION_FORM_FIELDS = [
64
+ { workflowId: '100003460000000485', fieldIds: ['100003720000009575', '100003720000009533'] },
65
+ { workflowId: '100003460000001155', fieldIds: ['100003720000022823', '100003720000022821'] },
66
+ { workflowId: '100003460000000060', fieldIds: ['100003720000000088', '100003720000000080'] },
67
+ { workflowId: '100003460000002579', fieldIds: ['100003720000110935', '100003720000009237'] },
68
+ { workflowId: '1047847083988836355', fieldIds: ['100501870166669555'] },
69
+ ];
70
+ // ---------------------------------------------------------------------------
71
+ // 工具函数
72
+ // ---------------------------------------------------------------------------
73
+ function pickText(source, ...keys) {
74
+ if (!source || typeof source !== 'object')
75
+ return '';
76
+ const record = source;
77
+ for (const key of keys) {
78
+ const value = record[key];
79
+ if (value !== undefined && value !== null && String(value).trim() !== '') {
80
+ return String(value).trim();
81
+ }
82
+ }
83
+ return '';
84
+ }
85
+ function pickRaw(source, ...keys) {
86
+ if (!source || typeof source !== 'object')
87
+ return undefined;
88
+ const record = source;
89
+ for (const key of keys) {
90
+ if (record[key] !== undefined)
91
+ return record[key];
92
+ }
93
+ return undefined;
94
+ }
95
+ /** 流程状态文本(来源:Python flow_status_text) */
96
+ function flowStatusText(value) {
97
+ if (value && typeof value === 'object')
98
+ return pickText(value, 'desc', 'name', 'label', 'text', 'value');
99
+ return stripHtml(value);
100
+ }
101
+ /** 紧急程度文本(来源:Python request_level_text) */
102
+ function requestLevelText(value) {
103
+ const text = stripHtml(value);
104
+ if (text === '0' || text === '正常')
105
+ return '正常';
106
+ if (text === '1' || text === '重要')
107
+ return '重要';
108
+ if (text === '2' || text === '紧急')
109
+ return '紧急';
110
+ return text;
111
+ }
112
+ /** 解析 OA 返回的本地时间文本(来源:Python parse_local_datetime) */
113
+ function parseLocalDateTime(value) {
114
+ let text = stripHtml(value);
115
+ if (!text)
116
+ return null;
117
+ text = text.replace(/T/g, ' ').replace(/\//g, '-').trim();
118
+ text = text.replace(/\.\d+/g, '');
119
+ text = text.replace(/(Z|[+-]\d{2}:?\d{2})$/, '').trim();
120
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(text);
121
+ if (!match)
122
+ return null;
123
+ const parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4] ?? 0), Number(match[5] ?? 0), Number(match[6] ?? 0));
124
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
125
+ }
126
+ /** 距今时长的人话表达(来源:Python humanize_duration_since) */
127
+ function humanizeDurationSince(value) {
128
+ const start = parseLocalDateTime(value);
129
+ if (!start)
130
+ return '';
131
+ const seconds = Math.floor((Date.now() - start.getTime()) / 1000);
132
+ if (seconds < 0)
133
+ return '';
134
+ const minutes = Math.floor(seconds / 60);
135
+ if (minutes < 1)
136
+ return '刚刚';
137
+ const hours = Math.floor(minutes / 60);
138
+ const days = Math.floor(hours / 24);
139
+ if (days >= 1) {
140
+ const remainHours = hours % 24;
141
+ return remainHours ? `${days}天${remainHours}小时` : `${days}天`;
142
+ }
143
+ if (hours >= 1) {
144
+ const remainMinutes = minutes % 60;
145
+ return remainMinutes ? `${hours}小时${remainMinutes}分钟` : `${hours}小时`;
146
+ }
147
+ return `${minutes}分钟`;
148
+ }
149
+ const BADGE_STYLES = {
150
+ danger: 'background:#FFF1F0;color:#D93025;border:1px solid #FFA39E;',
151
+ warning: 'background:#FFF7E6;color:#AD6800;border:1px solid #FFD591;',
152
+ info: 'background:#E6F0FF;color:#1D4ED8;border:1px solid #A7C7FF;',
153
+ neutral: 'background:#F4F6F8;color:#5F6B7A;border:1px solid #D8DEE6;',
154
+ };
155
+ /** 常规状态不显示徽标(来源:Python REGULAR_STATUS_BADGES) */
156
+ const REGULAR_STATUS_BADGES = new Set(['审批中', '处理中', '待处理']);
157
+ /** 徽标颜色类型(来源:Python badge_type) */
158
+ function badgeType(label) {
159
+ if (label === '已超时')
160
+ return 'danger';
161
+ if (label === '已结束' || label === '结束')
162
+ return 'neutral';
163
+ if (label === '草稿' || label === '待提交')
164
+ return 'warning';
165
+ if (REGULAR_STATUS_BADGES.has(label))
166
+ return 'info';
167
+ return 'neutral';
168
+ }
169
+ function buildBadgeObjects(labels) {
170
+ return labels.map((label) => {
171
+ const kind = badgeType(label);
172
+ return { text: label, type: kind, style: BADGE_STYLES[kind] };
173
+ });
174
+ }
175
+ /** 流程状态是否值得显示徽标(来源:Python should_display_flow_status_badge) */
176
+ function shouldDisplayFlowStatusBadge(label) {
177
+ const text = stripHtml(label);
178
+ if (!text)
179
+ return false;
180
+ if (REGULAR_STATUS_BADGES.has(text))
181
+ return false;
182
+ if (text.startsWith('审批中'))
183
+ return false;
184
+ return true;
185
+ }
186
+ /** 卡片第三行元信息,按 category 选字段(来源:Python build_card_meta) */
187
+ function buildCardMeta(item, category) {
188
+ let candidates;
189
+ if (category === 'mine') {
190
+ candidates = [
191
+ ['当前节点', item.currentNode],
192
+ ['发起时间', item.createdAt],
193
+ ];
194
+ }
195
+ else if (category === 'todo') {
196
+ candidates = [
197
+ ['发起人', item.creatorName],
198
+ ['当前节点', item.currentNode],
199
+ ['停留时长', item.stayDuration],
200
+ ];
201
+ }
202
+ else {
203
+ candidates = [
204
+ ['发起人', item.creatorName],
205
+ ['当前节点', item.currentNode],
206
+ ['发起时间', item.createdAt],
207
+ ];
208
+ }
209
+ return candidates
210
+ .map(([label, value]) => ({ label, value: asString(value) ?? '' }))
211
+ .filter((pair) => pair.value);
212
+ }
213
+ /** 卡片标题点击跳转 OA 详情页 URL(来源:Python card_workflow_open_url) */
214
+ function cardWorkflowOpenUrl(card, baseUrl) {
215
+ const internal = card.internal || {};
216
+ const requestId = asString(internal.requestId) ?? '';
217
+ if (!requestId)
218
+ return '';
219
+ const root = baseUrl.replace(/\/+$/, '');
220
+ if (!root)
221
+ return '';
222
+ const userMasterId = asString(internal.userMasterId) ?? '';
223
+ const base = `${root}/sp/workflow/flowpage/view/${encodeURIComponent(requestId)}`;
224
+ return userMasterId
225
+ ? `${base}?secondEmpId=${encodeURIComponent(userMasterId)}&requestId=${encodeURIComponent(requestId)}`
226
+ : base;
227
+ }
228
+ /** 解析 "名称(ID)" 或纯 ID / 纯名称 → (name, id)。ID 必须是纯数字才认定为 ID。 */
229
+ function parseNameWithId(value) {
230
+ const text = (value || '').trim();
231
+ if (!text)
232
+ return { name: '' };
233
+ const match = /^(.+?)[((]([^()()]*)[))]\s*$/.exec(text);
234
+ if (match) {
235
+ const name = match[1].trim();
236
+ const idPart = match[2].trim();
237
+ if (name && idPart && /^[0-9]+$/.test(idPart)) {
238
+ return { name, id: idPart };
239
+ }
240
+ }
241
+ return { name: text };
242
+ }
243
+ function splitCsv(value) {
244
+ if (value === undefined || value === null)
245
+ return [];
246
+ const text = String(value).trim();
247
+ if (!text)
248
+ return [];
249
+ return text.split(',').map((item) => item.trim()).filter(Boolean);
250
+ }
251
+ // ---------------------------------------------------------------------------
252
+ // 时间条件取值(口径与 V11.3 完全一致)
253
+ //
254
+ // 取值一律是「开始 结束」:YYYY-MM-DD,单边留空串表示该端不限;只写一个值(两侧无空白)
255
+ // 会被补成 "值 值",限到当天。**不接受相对日期词**("本周/上月/近7天")——脚本不做中文时间
256
+ // 归一化,模型需自己换算成具体日期后传入(来源:Python workflow_intent_from_args +
257
+ // build_system_date_conditions,V11.3 明确"相对时间词/触发词/绝对日期均不再自动提取")。
258
+ //
259
+ // 非法取值(格式不符 / 2026-02-30 / start > end)按 V11.3 **静默丢弃该条件**,
260
+ // 查询继续执行——不抛错、不把原文透传给服务端。
261
+ // ---------------------------------------------------------------------------
262
+ /** 严格校验 YYYY-MM-DD 且为真实日期(来源:Python date.fromisoformat + validate_date_content)。 */
263
+ function isValidIsoDate(text) {
264
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(text);
265
+ if (!match)
266
+ return false;
267
+ const year = Number(match[1]);
268
+ const month = Number(match[2]);
269
+ const day = Number(match[3]);
270
+ if (month < 1 || month > 12 || day < 1 || day > 31)
271
+ return false;
272
+ const date = new Date(year, month - 1, day);
273
+ // 反校验:2026-02-30 会被 Date 归一化成 03-02,必须判非法
274
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
275
+ }
276
+ /**
277
+ * 单边取值 → 日期串。空串原样返回(该端不限);非法返回 null。
278
+ * 调用方按 V11.3 语义决定:时间窗条件丢弃该条件,表单日期字段报错。
279
+ */
280
+ function resolveDateBound(raw) {
281
+ const text = (raw === undefined || raw === null ? '' : String(raw)).trim();
282
+ if (!text)
283
+ return '';
284
+ return isValidIsoDate(text) ? text : null;
285
+ }
286
+ /**
287
+ * "开始 结束"(YYYY-MM-DD,单边可空串);单值(两侧无空白)自动补成 "值 值"(限到当天)。
288
+ *
289
+ * 切分必须基于**原始串**判断有没有第二个空格——先 trim 会把 `"2026-08-01 "`(开放右边界)
290
+ * 误判成单值、把范围收窄到当天(来源:Python parse_time_span_parts)。
291
+ *
292
+ * 任一边非法或 start > end → null(V11.3 静默丢弃该条件,查询继续)。
293
+ */
294
+ function parseDateRange(value) {
295
+ if (value === undefined || value === null)
296
+ return null;
297
+ const raw = String(value);
298
+ const matched = /^(\S*)\s+(.*)$/su.exec(raw);
299
+ const start = resolveDateBound(matched ? matched[1] : raw);
300
+ const end = resolveDateBound(matched ? matched[2] : raw);
301
+ if (start === null || end === null)
302
+ return null;
303
+ if (!start && !end)
304
+ return null;
305
+ if (start && end && start > end)
306
+ return null;
307
+ return [start, end];
308
+ }
309
+ /** 四个时间窗参数的取值说明(原文照抄 V11.3 argparse help,参数名适配为 schema 字段名) */
310
+ const TIME_DESCRIPTIONS = {
311
+ createTime: "发起时间条件:'开始 结束'(YYYY-MM-DD),单边用空串,如 '2026-08-01 2026-08-07' 或 ' 2026-08-07'",
312
+ operateTime: "操作时间条件:'开始 结束'(YYYY-MM-DD),单边用空串;待办(todo)分类下此条件会被忽略(待办=还未处理)",
313
+ receiveTime: "接收时间条件:'开始 结束'(YYYY-MM-DD),单边用空串,如 '2026-08-07 2026-08-07'(\"今天提交到我待办的\"→receiveTime)",
314
+ finishTime: "完成/办结时间条件:'开始 结束'(YYYY-MM-DD),单边用空串",
315
+ };
316
+ const SYSTEM_FIELDS = {
317
+ workflowid: {
318
+ endpoint: WORKFLOW_ENDPOINTS.WORKFLOW_PATH_BROWSER,
319
+ extra: { browserMultiple: true, bridgeService: '' },
320
+ },
321
+ creator: {
322
+ endpoint: WORKFLOW_ENDPOINTS.RESOURCE_BROWSER,
323
+ extra: {
324
+ browserMultiple: true,
325
+ authWecahtBook: false,
326
+ wxDepNeedConvertCount: 0,
327
+ wxEmpNeedConvertCount: 0,
328
+ wxTotalNeedConvertCount: 0,
329
+ },
330
+ },
331
+ creatordept: { endpoint: WORKFLOW_ENDPOINTS.DEPT_BROWSER, extra: { browserMultiple: true } },
332
+ creatorsubcom: { endpoint: WORKFLOW_ENDPOINTS.SUBCOMPANY_BROWSER, extra: { browserMultiple: true } },
333
+ workflowtype: { endpoint: WORKFLOW_ENDPOINTS.WORKFLOW_TYPE_BROWSER, extra: { browserMultiple: true } },
334
+ };
335
+ /**
336
+ * 调 browser complete 做名称转 ID,返回**全部**候选 `{id, name}`(0/1/多)。
337
+ * 请求失败按 0 命中处理(不阻断主流程,由调用方决定报错还是澄清)。
338
+ */
339
+ async function resolveNameCandidates(env, config, keyword) {
340
+ const text = (keyword || '').trim();
341
+ if (!text)
342
+ return [];
343
+ const body = compact({ browserMultiple: true, newly: 1, searchValue: text, ...config.extra });
344
+ let response;
345
+ try {
346
+ response = await env.host.workflowRequest({
347
+ method: 'POST',
348
+ path: config.endpoint,
349
+ operation: 'workflow.nameComplete',
350
+ body,
351
+ });
352
+ }
353
+ catch (error) {
354
+ // 认证/网络错误不得当成"0 命中"——否则登录过期会被报成「工作流名称不存在」(见 rethrowAuthOrNetworkError)
355
+ rethrowAuthOrNetworkError(error);
356
+ return [];
357
+ }
358
+ const data = responseData(response);
359
+ const list = toArray(data?.data?.data ?? data?.data ?? data);
360
+ const resolved = [];
361
+ const seen = new Set();
362
+ for (const item of list) {
363
+ if (!item || typeof item !== 'object')
364
+ continue;
365
+ const id = pickText(item, 'id');
366
+ if (!id || seen.has(id))
367
+ continue;
368
+ seen.add(id);
369
+ resolved.push({ id, name: pickText(item, 'name', 'content') || text });
370
+ }
371
+ return resolved;
372
+ }
373
+ /** 发起人自称词(= 当前用户,跳过名称匹配;来源:Python SELF_KEYWORDS)。 */
374
+ const CREATOR_SELF_TERMS = new Set(['我', '我的', '本人', '我自己', '当前用户', '自己']);
375
+ /**
376
+ * 系统字段 → 澄清标识(对齐 V11.3 `name2id_clarification`,L8244:
377
+ * `clarificationType = f"system_relate_name2id_{system_field}"`)。
378
+ *
379
+ * 例外:`--workflow` 的**纳入**条件在 V11.3 用的是简写 `workflow`(L10393,工作流主实体块);
380
+ * `system_relate_name2id_workflowid` 只出现在排除条件里(且被 `workflow_name_exclusion` 覆盖)。
381
+ */
382
+ const SYSTEM_CLARIFICATION_TYPES = {
383
+ workflowid: 'workflow',
384
+ creator: 'system_relate_name2id_creator',
385
+ creatordept: 'system_relate_name2id_creatordept',
386
+ creatorsubcom: 'system_relate_name2id_creatorsubcom',
387
+ workflowtype: 'system_relate_name2id_workflowtype',
388
+ };
389
+ /** 候选 → 澄清载荷(`display` 即用户确认后回传的 `名称(ID)` 格式)。 */
390
+ function clarificationCandidates(items, limit) {
391
+ return items.slice(0, limit).map((item, index) => ({
392
+ index: index + 1,
393
+ id: item.id,
394
+ name: item.name,
395
+ display: `${item.name}(${item.id})`,
396
+ }));
397
+ }
398
+ /**
399
+ * 系统字段名称 → ID。
400
+ *
401
+ * 解析结果直接决定查询条件是否生效,因此**不允许静默降级**——但"澄清"与"失败"的边界
402
+ * **逐字对齐 V11.3**(不是整类都澄清):
403
+ * - **`--workflow` 名 0 命中 → 澄清**(`e10_workflow.py:10356-10375`:`success:true` +
404
+ * `needClarification:true` + `clarificationQuestion`,**不带候选**);
405
+ * - **其余系统字段(发起人/部门/分部/工作流类型)0 命中 → 失败**
406
+ * (`:10924-10927` `fail("系统字段名称转 ID 未匹配到数据", systemField=…, keyword=…)`);
407
+ * - **多命中 → 澄清**并列出候选(`name2id_clarification`,`:8223-8244`;工作流名走 `:10382-10400`);
408
+ * - 多命中但**有唯一全名精确匹配**(`normalize_compare_text` 口径)→ 直接用,不澄清。
409
+ */
410
+ async function resolveTerm(env, systemKey, rawValue, label) {
411
+ const text = asString(rawValue);
412
+ if (!text)
413
+ return null;
414
+ const parsed = parseNameWithId(text);
415
+ if (parsed.id)
416
+ return { id: parsed.id, name: parsed.name || text };
417
+ if (systemKey === 'creator' && CREATOR_SELF_TERMS.has(text))
418
+ return null;
419
+ const candidates = await resolveNameCandidates(env, SYSTEM_FIELDS[systemKey], text);
420
+ const clarificationType = SYSTEM_CLARIFICATION_TYPES[systemKey];
421
+ if (!candidates.length) {
422
+ // 0 命中:只有 --workflow 澄清,其余系统字段一律失败(V11.3 口径)
423
+ if (systemKey === 'workflowid') {
424
+ throw workflowClarificationError(clarificationType, text, `${label}「${text}」未匹配到任何对象。请核对名称(是否写错字/少了字),`
425
+ + `或提供准确的完整名称(也可直接给工作流 ID)后再查。`);
426
+ }
427
+ throw workflowValidationError(`${systemKey}_not_found`, `${label}「${text}」未匹配到任何对象(该筛选条件不会生效,继续执行会返回不含该条件的全量数据),`
428
+ + '本次查询已停止。请核对名称,或改用「名称(ID)」/ 数字 ID 后重发。');
429
+ }
430
+ const exact = candidates.filter((item) => item.name.trim().toLowerCase() === text.trim().toLowerCase());
431
+ if (exact.length === 1)
432
+ return exact[0];
433
+ if (candidates.length === 1)
434
+ return candidates[0];
435
+ // 多命中:优先用全名精确匹配的那一批(对齐 V11.3 的 exact_candidates 优选)
436
+ const selected = exact.length ? exact : candidates;
437
+ if (selected.length === 1)
438
+ return selected[0];
439
+ throw workflowClarificationError(clarificationType, text, `${label}「${text}」匹配到 ${selected.length} 个候选项,请确认要查询的具体${label}。`, clarificationCandidates(selected, 10));
440
+ }
441
+ /**
442
+ * 读取当前环境的紧急程度列表(来源:Python QUERY_DEPENDENCY → data.requestLevelList)。
443
+ *
444
+ * **字段名是 `levelName`**(不是 `name`/`showname`)——写错会让整个列表解析为空,
445
+ * 进而让紧急程度条件被静默丢弃(退化为全量查询且看不出来)。
446
+ */
447
+ async function fetchRequestLevels(env) {
448
+ const response = await env.host.workflowRequest({
449
+ method: 'GET',
450
+ path: WORKFLOW_ENDPOINTS.QUERY_DEPENDENCY,
451
+ operation: 'workflow.queryDependency',
452
+ });
453
+ const data = responseData(response);
454
+ const list = toArray(data?.requestLevelList ?? data?.data?.requestLevelList);
455
+ const levels = [];
456
+ const seen = new Set();
457
+ for (const item of list) {
458
+ if (!item || typeof item !== 'object')
459
+ continue;
460
+ const id = pickText(item, 'id');
461
+ if (!id || seen.has(id))
462
+ continue;
463
+ seen.add(id);
464
+ levels.push({ id, name: pickText(item, 'levelName', 'name') });
465
+ }
466
+ return levels;
467
+ }
468
+ /**
469
+ * 紧急程度名称 → requestLevelList 中的 id(来源:Python request_level_condition,L8313)。
470
+ *
471
+ * 解析结果决定查询条件是否生效,因此**不允许静默降级**(旧实现用 `catch {}` 静默丢弃该条件,
472
+ * 使「按紧急程度筛选」退化为不筛选)。澄清/失败边界**逐字对齐 V11.3**:
473
+ * - `名称(ID)`(澄清回传)/ 纯数字 ID → 直接取 ID;
474
+ * - 名称唯一命中 → 用其 id;
475
+ * - **多命中 → 澄清**(`system_relate_name2id_requestlevel` + 候选,对齐 V11.3 L8352-8363);
476
+ * - **0 命中 → 失败**(V11.3 `:10938-10939` `fail("紧急程度未匹配到等级数据")`;可选等级写在
477
+ * message 里,不另给结构化候选);
478
+ * - 列表接口拿不到数据 / 列表为空 → 抛 `requestlevel_unavailable`(V11.3 同样 `fail`)。
479
+ */
480
+ async function resolveRequestLevel(env, rawValue) {
481
+ const text = asString(rawValue);
482
+ if (!text)
483
+ return null;
484
+ const parsed = parseNameWithId(text);
485
+ if (parsed.id)
486
+ return parsed.id;
487
+ if (/^[0-9]+$/.test(text))
488
+ return text;
489
+ let levels = [];
490
+ try {
491
+ levels = await fetchRequestLevels(env);
492
+ }
493
+ catch (error) {
494
+ rethrowAuthOrNetworkError(error);
495
+ throw workflowValidationError('requestlevel_unavailable', `未能读取当前环境的紧急程度列表(接口无返回),无法解析紧急程度「${text}」,本次查询已停止。请稍后重试。`);
496
+ }
497
+ if (!levels.length) {
498
+ throw workflowValidationError('requestlevel_unavailable', `当前环境的紧急程度列表为空,无法解析紧急程度「${text}」,本次查询已停止。`);
499
+ }
500
+ const matched = levels.filter((level) => level.name && level.name.includes(text));
501
+ if (matched.length === 1)
502
+ return matched[0].id;
503
+ const listed = levels.map((level, index) => `${index + 1}. ${level.name}(${level.id})`).join(';');
504
+ if (matched.length > 1) {
505
+ // 多命中时**先按全名精确匹配优选**(与 resolveTerm / createList 同一套判据):
506
+ // 候选里有唯一的 equals 项就直接用,不必让用户在"紧急"与"特急紧急"之间做选择。
507
+ const exact = matched.filter((level) => level.name.trim().toLowerCase() === text.trim().toLowerCase());
508
+ if (exact.length === 1)
509
+ return exact[0].id;
510
+ const selected = exact.length ? exact : matched;
511
+ throw workflowClarificationError('system_relate_name2id_requestlevel', text, `紧急程度「${text}」匹配到 ${selected.length} 个等级,请确认要查询的具体等级。`, clarificationCandidates(selected, 10));
512
+ }
513
+ throw workflowValidationError('requestlevel_not_found', `紧急程度「${text}」未匹配到任何等级(该筛选条件不会生效),本次查询已停止。`
514
+ + `当前环境可选等级:${listed}。请核对后改用「名称(ID)」重发。`);
515
+ }
516
+ /** 时间条件 → formDatas 顶层键(来源:Python MEMORY_DATE_FORM_KEYS)。取值非法时按 V11.3 静默丢弃该条件。 */
517
+ function applyTimeCondition(formDatas, fieldKey, raw) {
518
+ const range = parseDateRange(raw);
519
+ if (!range)
520
+ return;
521
+ formDatas[fieldKey] = [{ dateContent: range, term: 'like' }];
522
+ }
523
+ // ---------------------------------------------------------------------------
524
+ // 表单字段筛选(filterFields)——字段名 → 真实字段解析 + 按类型生成 filterFormDatas 项
525
+ //
526
+ // 来源:Python fetch_search_ad_fields / build_model_filter_fields /
527
+ // workflow_field_condition_clarification(L7031/L7096/L7596/L9842)。
528
+ //
529
+ // 铁律:**filterFormDatas 项必须带 componentKey + fieldId**——服务端只认这两个键,
530
+ // 只传 fieldName 会被服务端**静默忽略**(条件蒸发、返回未筛选的全量数据且不报错,
531
+ // 实测 `{"fieldName":"报销总金额",...}` 与不传条件返回条数完全一致)。
532
+ // 因此字段名解析不出来时一律报错让模型与用户澄清,**绝不原样直传**。
533
+ // ---------------------------------------------------------------------------
534
+ /** 文本类组件(值走 content) */
535
+ const TEXT_FIELD_COMPONENT_KEYS = new Set(['Text', 'TextArea', 'Email', 'Phone', 'Mobile', 'IDCard']);
536
+ /** 数值/金额类组件(值走 numberContent{min,max}) */
537
+ const NUMBER_FIELD_COMPONENT_KEYS = new Set(['NumberComponent', 'Money']);
538
+ /** 金额类组件:严格边界步长 0.01,其余数值 0.000001(来源:Python adjust_exclusive_number_bound) */
539
+ const MONEY_FIELD_COMPONENT_KEYS = new Set(['Money']);
540
+ /** 日期类组件(值走 dateContent[开始,结束]) */
541
+ const DATE_FIELD_COMPONENT_KEYS = new Set(['DateComponent']);
542
+ const TEXT_FIELD_TERMS = new Set(['like', 'notlike', 'eq', 'ne', 'null', 'notnull']);
543
+ const NUMBER_FIELD_TERMS = new Set(['like', 'gt', 'gte', 'lt', 'lte', 'null', 'notnull']);
544
+ const DATE_FIELD_TERMS = new Set(['like', 'null', 'notnull']);
545
+ const RELATE_FIELD_TERMS = new Set(['eq', 'ne', 'in', 'null', 'notnull']);
546
+ /** 中文/别名 term → 规范 term */
547
+ const FILTER_TERM_ALIASES = {
548
+ 等于: 'eq', 不等于: 'ne', 属于: 'in', 包含: 'like', 不包含: 'notlike',
549
+ 大于: 'gt', 大于等于: 'gte', 小于: 'lt', 小于等于: 'lte',
550
+ };
551
+ /** 字段元数据进程内缓存(每次 CLI 调用一个进程;同一次 plan 多组查询可复用)。 */
552
+ const SEARCH_AD_FIELDS_CACHE = new Map();
553
+ const SEARCH_AD_FIELDS_CACHE_MAX = 8;
554
+ /**
555
+ * 取某工作流可用的表单查询字段(来源:Python fetch_search_ad_fields,L9842)。
556
+ *
557
+ * 接口:`getListSearchAdWithForm`;表单字段在 `data.layout[1]`(`layout[0]` 是系统字段),
558
+ * 每个字段的组件类型/浏览按钮信息在 `items["<fieldId>Content"]` / `items["<fieldId>_item"]`。
559
+ */
560
+ async function fetchWorkflowSearchFields(env, workflowId, category, tabid) {
561
+ const cacheKey = `${category}|${tabid}|${workflowId}`;
562
+ const cached = SEARCH_AD_FIELDS_CACHE.get(cacheKey);
563
+ if (cached)
564
+ return cached;
565
+ let response;
566
+ try {
567
+ response = await env.host.workflowRequest({
568
+ method: 'POST',
569
+ path: WORKFLOW_ENDPOINTS.SEARCH_AD_WITH_FORM,
570
+ operation: 'workflow.searchAdWithForm',
571
+ body: {
572
+ dimension: String(category || 'todo'),
573
+ searchAdWorkflowid: String(workflowId),
574
+ fromWfList: true,
575
+ tabid: String(tabid === undefined || tabid === null || tabid === '' ? '0' : tabid),
576
+ },
577
+ });
578
+ }
579
+ catch (error) {
580
+ rethrowAuthOrNetworkError(error);
581
+ // V11.3 口径(`e10_workflow.py:7045-7056`):字段元数据**读不到**时返回
582
+ // `needClarification:true`(不带候选),把归因指向数据/环境问题——而不是判失败让模型
583
+ // 继续猜字段名,陷入"猜字段名-失败-再猜"的无解循环。
584
+ throw workflowClarificationError('field_meta', workflowId, `已匹配到工作流「${workflowId}」,但暂时无法获取其表单字段清单(表单查询字段接口无返回)。`
585
+ + '请稍后重试;若持续失败,请确认该工作流是否有可用的表单字段数据。');
586
+ }
587
+ const data = responseData(response);
588
+ const items = (data?.items && typeof data.items === 'object' ? data.items : {});
589
+ const layout = toArray(data?.layout);
590
+ const segment = toArray(layout.length > 1 ? layout[1] : layout[layout.length - 1]);
591
+ const fields = [];
592
+ const seenIds = new Set();
593
+ for (const entry of segment) {
594
+ if (!entry || typeof entry !== 'object')
595
+ continue;
596
+ const fieldId = pickText(entry, 'id');
597
+ const label = pickText(entry, 'label');
598
+ if (!fieldId || !label || seenIds.has(fieldId))
599
+ continue;
600
+ seenIds.add(fieldId);
601
+ const contentItem = (items[`${fieldId}Content`] && typeof items[`${fieldId}Content`] === 'object'
602
+ ? items[`${fieldId}Content`] : {});
603
+ const itemItem = (items[`${fieldId}_item`] && typeof items[`${fieldId}_item`] === 'object'
604
+ ? items[`${fieldId}_item`] : {});
605
+ const contentParams = (contentItem.customParameters && typeof contentItem.customParameters === 'object'
606
+ ? contentItem.customParameters : {});
607
+ const itemParams = (itemItem.customParameters && typeof itemItem.customParameters === 'object'
608
+ ? itemItem.customParameters : {});
609
+ // V11.3 是 `field.get("browserBean")` 的真值判断——**空对象 `{}` 在 Python 里是 False**,
610
+ // 而 JS 的 `Boolean({})` 恒为 true。这里必须判"非空对象",否则一个空的 browserBean 会让
611
+ // 该字段(Python 里会被跳过)被判成浏览按钮字段,筛选值走 name→ID 后误报未匹配。
612
+ const rawBrowserBean = contentItem.browserBean;
613
+ const browserBean = (rawBrowserBean && typeof rawBrowserBean === 'object'
614
+ && Object.keys(rawBrowserBean).length > 0)
615
+ ? rawBrowserBean : undefined;
616
+ let componentKey = pickText(contentParams, 'componentKey') || pickText(itemParams, 'componentKey');
617
+ if (!componentKey && browserBean)
618
+ componentKey = 'BROWSER';
619
+ if (!componentKey)
620
+ continue;
621
+ const baseName = label.split('.')[0].trim();
622
+ const matchNames = baseName && baseName !== label ? [label, baseName] : [label];
623
+ const meta = { fieldId, fieldName: label, matchNames, componentKey };
624
+ if (browserBean) {
625
+ meta.browserBean = {
626
+ module: pickText(browserBean, 'module'),
627
+ type: pickText(browserBean, 'type'),
628
+ commonParams: (browserBean.commonParams && typeof browserBean.commonParams === 'object')
629
+ ? browserBean.commonParams : undefined,
630
+ };
631
+ }
632
+ fields.push(meta);
633
+ }
634
+ if (SEARCH_AD_FIELDS_CACHE.size >= SEARCH_AD_FIELDS_CACHE_MAX)
635
+ SEARCH_AD_FIELDS_CACHE.clear();
636
+ SEARCH_AD_FIELDS_CACHE.set(cacheKey, fields);
637
+ return fields;
638
+ }
639
+ function fieldCandidateItem(field, index) {
640
+ return {
641
+ index,
642
+ fieldId: field.fieldId,
643
+ fieldName: field.fieldName,
644
+ componentKey: field.componentKey,
645
+ display: `${field.fieldName}(${field.fieldId})`,
646
+ };
647
+ }
648
+ /** 候选字段清单:优先列名称与请求词有共同字/被请求词包含的字段,再补足到 limit 个。 */
649
+ function fieldCandidateList(fields, requested, limit = 12) {
650
+ const chars = [...new Set([...requested].filter((c) => /[\u4e00-\u9fa5A-Za-z0-9]/.test(c)))];
651
+ const scored = fields.map((field) => {
652
+ const names = field.matchNames.join('|');
653
+ let score = 0;
654
+ for (const c of chars)
655
+ if (names.includes(c))
656
+ score += 1;
657
+ if (names.includes(requested))
658
+ score += 5;
659
+ return { score, field };
660
+ });
661
+ scored.sort((a, b) => b.score - a.score);
662
+ return scored.slice(0, limit).map((item, index) => fieldCandidateItem(item.field, index + 1));
663
+ }
664
+ /** 严格边界调整:gt → 下界 + 步长,lt → 上界 − 步长(来源:Python adjust_exclusive_number_bound)。 */
665
+ function adjustExclusiveBound(rawValue, isMoney, direction) {
666
+ const num = Number(rawValue);
667
+ if (!Number.isFinite(num))
668
+ return rawValue;
669
+ const step = isMoney ? 0.01 : 0.000001;
670
+ const adjusted = direction === 'min' ? num + step : num - step;
671
+ return isMoney ? adjusted.toFixed(2) : String(Number(adjusted.toFixed(6)));
672
+ }
673
+ /** 表单浏览按钮字段取值候选(来源:Python form_browser_complete_candidates)。 */
674
+ async function fetchFieldValueCandidates(env, field, keyword) {
675
+ const bean = field.browserBean;
676
+ const module = asString(bean?.module) ?? '';
677
+ const type = asString(bean?.type) ?? '';
678
+ if (!module || !type)
679
+ return [];
680
+ const body = {
681
+ ...(bean?.commonParams ?? {}),
682
+ browserMultiple: true,
683
+ newly: 1,
684
+ searchValue: keyword,
685
+ };
686
+ let response;
687
+ try {
688
+ response = await env.host.workflowRequest({
689
+ method: 'POST',
690
+ path: `/api/${module}/common/browser/complete/${type}`,
691
+ operation: 'workflow.fieldValueComplete',
692
+ body,
693
+ });
694
+ }
695
+ catch (error) {
696
+ rethrowAuthOrNetworkError(error);
697
+ throw workflowValidationError('field_value_unavailable', `字段「${field.fieldName}」的取值解析接口调用失败(${module}/complete/${type}),本次查询已停止。请稍后重试。`);
698
+ }
699
+ const data = responseData(response);
700
+ const list = toArray(data?.data ?? data);
701
+ const out = [];
702
+ const seen = new Set();
703
+ for (const item of list) {
704
+ if (!item || typeof item !== 'object')
705
+ continue;
706
+ const id = pickText(item, 'id');
707
+ if (!id || seen.has(id))
708
+ continue;
709
+ seen.add(id);
710
+ out.push({ id, name: pickText(item, 'name', 'content') || id });
711
+ }
712
+ return out;
713
+ }
714
+ /**
715
+ * 把「用户写的字段值」解析为 relateContent 的 `{id,name}`(选项/人员/浏览类字段)。
716
+ *
717
+ * 解析不出唯一值 → **澄清**(`fieldOption` + 候选,对齐 V11.3
718
+ * `field_option_condition_clarification` L7131-7150:`clarificationType:"fieldOption"`),
719
+ * 绝不猜测、绝不静默放行。
720
+ */
721
+ async function resolveFieldValues(env, field, rawValues) {
722
+ const resolved = [];
723
+ const unresolved = [];
724
+ for (const raw of rawValues) {
725
+ if (raw === undefined || raw === null || !String(raw).trim())
726
+ continue;
727
+ const text = String(raw).trim();
728
+ const parsed = parseNameWithId(text);
729
+ if (parsed.id) {
730
+ resolved.push({ id: parsed.id, name: parsed.name || parsed.id });
731
+ continue;
732
+ }
733
+ const candidates = await fetchFieldValueCandidates(env, field, text);
734
+ const exact = candidates.filter((c) => c.name === text);
735
+ const contains = candidates.filter((c) => c.name.includes(text) || text.includes(c.name));
736
+ const picked = exact.length === 1 ? exact[0] : (exact.length === 0 && contains.length === 1 ? contains[0] : undefined);
737
+ if (picked) {
738
+ resolved.push(picked);
739
+ continue;
740
+ }
741
+ unresolved.push(text);
742
+ }
743
+ if (!unresolved.length)
744
+ return resolved;
745
+ // 解析不出唯一值 → 列出候选让用户确认(不猜测、不放行)
746
+ const all = await fetchFieldValueCandidates(env, field, '');
747
+ const listed = all.slice(0, 15).map((c, index) => `${index + 1}. ${c.name}(${c.id})`).join(';') || '(该字段未返回任何可选项)';
748
+ const more = all.length > 15 ? `(共 ${all.length} 个,仅列前 15 个)` : '';
749
+ const question = all.length
750
+ ? `字段「${field.fieldName}」中未找到与「${unresolved.join('、')}」唯一匹配的选项,`
751
+ + `请从下列选项确认一个(回复序号或「选项名(选项ID)」):${listed}${more}。`
752
+ : `字段「${field.fieldName}」没有可筛选的选项数据,无法生成该字段条件。`;
753
+ throw workflowClarificationError('fieldOption', unresolved.join('、'), question, clarificationCandidates(all, 15));
754
+ }
755
+ /** --filter-fields 单项 → filterFormDatas 项(字段名必须解析为真实字段,否则报错让模型澄清)。 */
756
+ async function resolveFilterFieldItem(env, raw, fields, workflowLabel, ordinal) {
757
+ if (!raw || typeof raw !== 'object') {
758
+ throw workflowValidationError('field_invalid', `filterFields 第 ${ordinal} 项必须是对象。`);
759
+ }
760
+ const item = raw;
761
+ const rawName = asString(item.fieldName) ?? '';
762
+ const rawTerm = (asString(item.term) ?? '').toLowerCase();
763
+ if (!rawName || !rawTerm) {
764
+ throw workflowValidationError('field_invalid', `filterFields 第 ${ordinal} 项必须同时提供 fieldName 与 term。`);
765
+ }
766
+ // 1) 字段名 → 真实字段(带 ID 直取 → 精确名称 → 包含匹配)。
767
+ // 解析不出来(0 命中/多命中)一律**澄清**(对齐 V11.3「字段未识别澄清」/「字段多候选澄清」,
768
+ // references/workflow-search.md「澄清后回传」表),由模型把候选摆给用户确认后用「字段名(字段ID)」重试。
769
+ const parsedName = parseNameWithId(rawName);
770
+ let field;
771
+ if (parsedName.id) {
772
+ field = fields.find((f) => f.fieldId === parsedName.id);
773
+ if (!field) {
774
+ throw workflowClarificationError('field', rawName, `工作流「${workflowLabel}」下没有字段 ID ${parsedName.id}(filterFields 第 ${ordinal} 项)。`
775
+ + `请确认该字段是否属于本工作流,或从下列可选字段中确认一个(回复「字段名(字段ID)」):`
776
+ + `${fieldCandidateList(fields, rawName).map((c) => c.display).join(';')}。`, fieldCandidateList(fields, rawName));
777
+ }
778
+ }
779
+ else {
780
+ const needle = parsedName.name.toLowerCase();
781
+ const exact = fields.filter((f) => f.matchNames.some((n) => n.toLowerCase() === needle));
782
+ if (exact.length === 1) {
783
+ field = exact[0];
784
+ }
785
+ else if (exact.length === 0) {
786
+ const contains = fields.filter((f) => f.matchNames.some((n) => {
787
+ const lower = n.toLowerCase();
788
+ return lower.includes(needle) || needle.includes(lower);
789
+ }));
790
+ if (contains.length === 1)
791
+ field = contains[0];
792
+ else if (contains.length > 1) {
793
+ throw workflowClarificationError('field', rawName, `工作流「${workflowLabel}」的字段名「${rawName}」匹配到 ${contains.length} 个字段(filterFields 第 ${ordinal} 项),`
794
+ + `请确认要查询的具体字段(回复「字段名(字段ID)」):`
795
+ + contains.slice(0, 15).map((f, index) => `${index + 1}. ${f.fieldName}(${f.fieldId})`).join(';')
796
+ + '。', contains.slice(0, 15).map((f, index) => fieldCandidateItem(f, index + 1)));
797
+ }
798
+ else {
799
+ throw workflowClarificationError('field', rawName, `工作流「${workflowLabel}」下未找到字段「${rawName}」(filterFields 第 ${ordinal} 项)。`
800
+ + `请从下列可选字段中确认一个(回复「字段名(字段ID)」):`
801
+ + `${fieldCandidateList(fields, rawName).map((c) => c.display).join(';')}。`, fieldCandidateList(fields, rawName));
802
+ }
803
+ }
804
+ else {
805
+ throw workflowClarificationError('field', rawName, `工作流「${workflowLabel}」的字段名「${rawName}」匹配到 ${exact.length} 个字段(filterFields 第 ${ordinal} 项),`
806
+ + `请确认要查询的具体字段(回复「字段名(字段ID)」):`
807
+ + exact.slice(0, 15).map((f, index) => `${index + 1}. ${f.fieldName}(${f.fieldId})`).join(';')
808
+ + '。', exact.slice(0, 15).map((f, index) => fieldCandidateItem(f, index + 1)));
809
+ }
810
+ }
811
+ // 2) term 规范化
812
+ const term = FILTER_TERM_ALIASES[rawTerm] ?? rawTerm;
813
+ const base = {
814
+ componentKey: field.componentKey,
815
+ fieldId: field.fieldId,
816
+ fieldName: field.fieldName,
817
+ };
818
+ const value = item.value;
819
+ // 3) 按组件类型生成报文(类型不支持时报错,不猜、不静默丢弃)
820
+ if (term === 'null' || term === 'notnull') {
821
+ const allowed = TEXT_FIELD_COMPONENT_KEYS.has(field.componentKey) ? TEXT_FIELD_TERMS
822
+ : NUMBER_FIELD_COMPONENT_KEYS.has(field.componentKey) ? NUMBER_FIELD_TERMS
823
+ : DATE_FIELD_COMPONENT_KEYS.has(field.componentKey) ? DATE_FIELD_TERMS : RELATE_FIELD_TERMS;
824
+ if (!allowed.has(term)) {
825
+ throw workflowValidationError('field_term_unsupported', `字段「${field.fieldName}」(${field.componentKey})不支持 term=${term}。`);
826
+ }
827
+ return { ...base, term };
828
+ }
829
+ if (TEXT_FIELD_COMPONENT_KEYS.has(field.componentKey)) {
830
+ if (!TEXT_FIELD_TERMS.has(term)) {
831
+ throw workflowValidationError('field_term_unsupported', `字段「${field.fieldName}」是文本字段,term 只能是 ${[...TEXT_FIELD_TERMS].join('/')};收到 term=${term}。`
832
+ + `若要做数值范围请改用数值字段(如金额类字段)。`);
833
+ }
834
+ const text = asString(value) ?? '';
835
+ if (!text) {
836
+ throw workflowValidationError('field_value_required', `字段「${field.fieldName}」的 term=${term} 需要提供 value。`);
837
+ }
838
+ return { ...base, term, content: text };
839
+ }
840
+ if (NUMBER_FIELD_COMPONENT_KEYS.has(field.componentKey)) {
841
+ if (!NUMBER_FIELD_TERMS.has(term)) {
842
+ throw workflowValidationError('field_term_unsupported', `字段「${field.fieldName}」是数值字段,term 只能是 ${[...NUMBER_FIELD_TERMS].join('/')};收到 term=${term}。`);
843
+ }
844
+ const isMoney = MONEY_FIELD_COMPONENT_KEYS.has(field.componentKey);
845
+ // gt/gte 只封下界、lt/lte 只封上界(另一侧留空串=开放),like 取 [min,max](标量视为闭区间单点)
846
+ let min = '';
847
+ let max = '';
848
+ if (term === 'like') {
849
+ if (Array.isArray(value)) {
850
+ min = asString(value[0]) ?? '';
851
+ max = asString(value[1]) ?? '';
852
+ }
853
+ else {
854
+ min = asString(value) ?? '';
855
+ max = min;
856
+ }
857
+ }
858
+ else {
859
+ const scalar = Array.isArray(value) ? (asString(value[0]) ?? '') : (asString(value) ?? '');
860
+ if (term === 'gt' || term === 'gte')
861
+ min = scalar;
862
+ else
863
+ max = scalar;
864
+ if (term === 'gt')
865
+ min = adjustExclusiveBound(min, isMoney, 'min');
866
+ if (term === 'lt')
867
+ max = adjustExclusiveBound(max, isMoney, 'max');
868
+ }
869
+ if (!min && !max) {
870
+ throw workflowValidationError('field_value_required', `字段「${field.fieldName}」的 term=${term} 需要提供 value(数值或 [min,max])。`);
871
+ }
872
+ return { ...base, term: 'like', numberContent: { min, max } };
873
+ }
874
+ if (DATE_FIELD_COMPONENT_KEYS.has(field.componentKey)) {
875
+ if (!DATE_FIELD_TERMS.has(term)) {
876
+ throw workflowValidationError('field_term_unsupported', `字段「${field.fieldName}」是日期字段,term 只能是 ${[...DATE_FIELD_TERMS].join('/')};收到 term=${term}。`);
877
+ }
878
+ const label = `字段「${field.fieldName}」`;
879
+ const range = Array.isArray(value) ? value : [value, value];
880
+ const start = resolveDateBound(range[0]);
881
+ const end = resolveDateBound(range[1]);
882
+ if (start === null || end === null || (!start && !end)) {
883
+ throw workflowValidationError('field_value_required', `字段「${field.fieldName}」的 term=${term} 需要提供 value(["开始","结束"],YYYY-MM-DD,单边可空串)。`);
884
+ }
885
+ if (start > end) {
886
+ throw workflowValidationError('time_invalid', `${label} 的时间区间起点晚于终点(${start} > ${end})。`);
887
+ }
888
+ return { ...base, term: 'like', dateContent: [start, end] };
889
+ }
890
+ // 同理:browserBean 为空对象时不算浏览按钮字段(见 extractSearchAdFields 的说明)。
891
+ const isBrowserField = (field.browserBean !== undefined
892
+ && Object.keys(field.browserBean).length > 0) || field.componentKey === 'BROWSER';
893
+ if (isBrowserField) {
894
+ const mapped = term === 'like' ? 'eq' : (term === 'notlike' ? 'ne' : term);
895
+ if (!RELATE_FIELD_TERMS.has(mapped)) {
896
+ throw workflowValidationError('field_term_unsupported', `字段「${field.fieldName}」是选项/人员/浏览类字段,term 只能是 ${[...RELATE_FIELD_TERMS].join('/')}(like/notLike 自动按 eq/ne 处理);收到 term=${term}。`);
897
+ }
898
+ const values = Array.isArray(value) ? value : [value];
899
+ if (!values.filter((v) => v !== undefined && v !== null && String(v).trim()).length) {
900
+ throw workflowValidationError('field_value_required', `字段「${field.fieldName}」的 term=${mapped} 需要提供 value。`);
901
+ }
902
+ const relateContent = await resolveFieldValues(env, field, values);
903
+ return { ...base, term: mapped === 'in' ? 'eq' : mapped, relateContent };
904
+ }
905
+ throw workflowValidationError('field_type_unsupported', `字段「${field.fieldName}」的组件类型 ${field.componentKey} 暂不支持作为筛选条件(filterFields 第 ${ordinal} 项),`
906
+ + '本次查询已停止。请改用文本/数值/日期/选项类字段。');
907
+ }
908
+ async function buildSearchQuery(env, args, mode) {
909
+ // 1. 原始 JSON 直传(--query-json / --query-file 透传;CLI 侧用 queryJson 字段承载)
910
+ const rawJson = args.queryJson;
911
+ let query;
912
+ if (rawJson && typeof rawJson === 'object') {
913
+ query = JSON.parse(JSON.stringify(rawJson));
914
+ }
915
+ else {
916
+ query = {};
917
+ }
918
+ // 2. 分类 + tabid
919
+ const category = (asString(args.category) || 'todo');
920
+ if (!ENDPOINT_PREFIX[category]) {
921
+ throw workflowValidationError('category_invalid', `category 必须是 todo/done/mine/all 之一,收到:${category}`);
922
+ }
923
+ let tabid = DEFAULT_TABID[category];
924
+ if (args.subCategory) {
925
+ const resolved = SUB_CATEGORY_TABS[category]?.[asString(args.subCategory) ?? ''];
926
+ if (resolved)
927
+ tabid = resolved;
928
+ }
929
+ if (args.tabName) {
930
+ const resolved = SUB_CATEGORY_TABS[category]?.[asString(args.tabName) ?? ''];
931
+ if (resolved)
932
+ tabid = resolved;
933
+ }
934
+ if (args.tabid !== undefined && String(args.tabid).trim() !== '') {
935
+ tabid = String(args.tabid).trim();
936
+ }
937
+ // 2.5 legacy(低版本模式)恢复 V11.2 语义:todo 未显式指定子分类时默认查「待处理(7)」而非
938
+ // 「全部(0)」(来源:Python execute_workflow_search_legacy 的 legacy tab 恢复分支,L12612-12627)。
939
+ // smart 智能分组取数需覆盖全部待办,故仅在 legacy 生效;显式 subCategory/tabName/tabid 或
940
+ // queryJson 自带 params.tabid 时保持原值不动(尊重显式意图)。
941
+ if (asString(args.searchMode) === 'legacy' && category === 'todo' && tabid === DEFAULT_TABID.todo) {
942
+ const explicitTab = Boolean(asString(args.subCategory) || asString(args.tabName) ||
943
+ (args.tabid !== undefined && String(args.tabid).trim() !== ''));
944
+ const rawTabid = query?.params?.tabid;
945
+ const rawHasTabid = rawTabid !== undefined && rawTabid !== null && String(rawTabid).trim() !== '';
946
+ if (!explicitTab && !rawHasTabid)
947
+ tabid = SUB_CATEGORY_TABS.todo['待处理'];
948
+ }
949
+ // 3. params 层
950
+ const params = {
951
+ tabid,
952
+ dimension: category,
953
+ flowSpareType: 1,
954
+ outType: 0,
955
+ };
956
+ if (tabid === '300' || tabid === '303' || tabid === '304') {
957
+ params.subDimensionId = 'dirParticipant';
958
+ }
959
+ if (mode === 'stat') {
960
+ params.needFormFields = true;
961
+ params.attentionFormFields = STAT_ATTENTION_FORM_FIELDS;
962
+ }
963
+ const requestIds = asString(args.requestId);
964
+ if (requestIds)
965
+ params.requestIds = requestIds;
966
+ const exRequestIds = asString(args.exRequestId);
967
+ if (exRequestIds)
968
+ params.exRequestIds = exRequestIds;
969
+ query.params = { ...params, ...compact(query.params || {}) };
970
+ // 4. formDatas
971
+ const formDatas = { ...compact(query.searchParams?.formDatas || {}) };
972
+ // 发起人 / 发起人部门 / 发起人分部 / 工作流类型 / 工作流(支持 "名称(ID)" 直取或多值)
973
+ // 解析失败一律抛错,不静默丢弃条件(否则条件不生效会退化为全量查询)
974
+ const creator = await resolveTerm(env, 'creator', args.creator, '发起人');
975
+ if (creator)
976
+ formDatas.creator = [{ browserDatas: [{ id: creator.id, name: creator.name }] }];
977
+ const creatorDept = await resolveTerm(env, 'creatordept', args.creatorDept, '发起人部门');
978
+ if (creatorDept)
979
+ formDatas.creatordept = [{ browserDatas: [{ id: creatorDept.id, name: creatorDept.name }] }];
980
+ const creatorSubcom = await resolveTerm(env, 'creatorsubcom', args.creatorSubcom, '发起人分部');
981
+ if (creatorSubcom)
982
+ formDatas.creatorsubcom = [{ browserDatas: [{ id: creatorSubcom.id, name: creatorSubcom.name }] }];
983
+ const workflowType = await resolveTerm(env, 'workflowtype', args.workflowType, '工作流类型');
984
+ if (workflowType)
985
+ formDatas.workflowtype = [{ browserDatas: [{ id: workflowType.id, name: workflowType.name }] }];
986
+ const workflows = asStringArray(args.workflow, 'workflow') ?? splitCsv(args.workflow).map((w) => [w]).flat();
987
+ // 解析后的工作流成员提升到外层:表单字段条件必须限定到单个工作流,需要在此消费同一份结果
988
+ const resolvedWorkflows = [];
989
+ if (workflows && workflows.length) {
990
+ const seenWorkflowIds = new Set();
991
+ for (const w of workflows) {
992
+ const resolved = await resolveTerm(env, 'workflowid', w, '工作流');
993
+ if (!resolved)
994
+ continue;
995
+ if (resolved.id && seenWorkflowIds.has(resolved.id))
996
+ continue;
997
+ if (resolved.id)
998
+ seenWorkflowIds.add(resolved.id);
999
+ resolvedWorkflows.push(resolved);
1000
+ }
1001
+ if (resolvedWorkflows.length) {
1002
+ // 多值必须合并为「单个条件项 + 多 browserDatas」(term 统一),逐值拆成多个条件项会被
1003
+ // 服务端按 AND 处理 → 多工作流查询恒 0 条(来源:Python _plan_workflow_form_datas / build_workflow_condition)
1004
+ const term = asString(args.workflowTerm) === 'notin' ? 'notin' : 'in';
1005
+ formDatas.workflowid = [{ term, browserDatas: resolvedWorkflows }];
1006
+ // workflowid 条件唯一时,取其首个 id 作为 params.searchAdWorkflowid
1007
+ // (来源:Python normalize_query_shape,list/total 两种请求体共用)
1008
+ const firstWorkflowId = asString(resolvedWorkflows[0]?.id) ?? '';
1009
+ if (firstWorkflowId) {
1010
+ query.params = { ...(query.params || {}), searchAdWorkflowid: firstWorkflowId };
1011
+ }
1012
+ }
1013
+ }
1014
+ // 流程标题 / 编号
1015
+ const requestName = asString(args.requestName);
1016
+ if (requestName) {
1017
+ const term = asString(args.requestNameTerm) || 'like';
1018
+ formDatas.requestname = [{ content: requestName, term }];
1019
+ }
1020
+ const requestMark = asString(args.requestMark);
1021
+ if (requestMark) {
1022
+ const term = asString(args.requestMarkTerm) || 'like';
1023
+ formDatas.requestmark = [{ content: requestMark, term }];
1024
+ }
1025
+ // 紧急程度
1026
+ const requestLevel = await resolveRequestLevel(env, args.requestLevel);
1027
+ if (requestLevel)
1028
+ formDatas.requestlevel = [{ content: requestLevel }];
1029
+ // 当前节点 ID
1030
+ const nodeId = asString(args.nodeId);
1031
+ if (nodeId) {
1032
+ const term = asString(args.nodeIdTerm) === 'notin' ? 'notin' : 'in';
1033
+ formDatas.nodeid = [{ content: nodeId, term }];
1034
+ }
1035
+ // 流程状态
1036
+ const flowStatus = asString(args.flowStatus);
1037
+ if (flowStatus) {
1038
+ const term = asString(args.flowStatusTerm) === 'notin' ? 'notin' : 'in';
1039
+ formDatas.flowstatus = [{ content: flowStatus, term }];
1040
+ }
1041
+ // 时间条件(来源:Python build_system_date_conditions——待办分类下移除操作时间条件:
1042
+ // 待办是"还未处理"的流程,操作时间(已操作过的时间)与待办状态语义矛盾)
1043
+ // ★ 键名大小写是服务端契约,不是笔误:V11.3 `FORM_KEYS`(e10_workflow.py:358-378)20 个条件键里
1044
+ // 只有 `operateDateTime` 与 `sysCode` 是驼峰,其余时间键(createdatetime/receivedatetime/
1045
+ // finishdatetime)都是全小写。V11.3 `:8369` 注释亦记「确认 OA 侧正确 key 为驼峰 operateDateTime」。
1046
+ // 曾误写全小写 `operatedatetime` ⇒ 服务端静默忽略该条件、已办查询退化成全量(实测四次不同取值
1047
+ // 返回同样条数),故此处必须保持驼峰。
1048
+ applyTimeCondition(formDatas, 'createdatetime', args.createTime);
1049
+ if (category !== 'todo')
1050
+ applyTimeCondition(formDatas, 'operateDateTime', args.operateTime);
1051
+ applyTimeCondition(formDatas, 'receivedatetime', args.receiveTime);
1052
+ applyTimeCondition(formDatas, 'finishdatetime', args.finishTime);
1053
+ // 表单字段条件:先解析字段名(→ 真实 fieldId/componentKey),解析不出就报错让模型澄清。
1054
+ // 服务端只认 componentKey + fieldId,只传 fieldName 会被静默忽略(条件蒸发、返回全量)。
1055
+ const filterFieldsRaw = parseFilterFieldsInput(args.filterFields);
1056
+ if (filterFieldsRaw.length) {
1057
+ if (!resolvedWorkflows.length) {
1058
+ throw workflowValidationError('field_require_workflow', '使用表单字段筛选时必须同时指定工作流(服务端只在该工作流的表单字段内匹配)。'
1059
+ + '请先与用户确认要查哪个工作流,再用「名称(ID)」传入 workflow,并附上 filterFields。');
1060
+ }
1061
+ if (resolvedWorkflows.length > 1) {
1062
+ throw workflowValidationError('field_require_single_workflow', `表单字段筛选一次只能用于**单个**工作流,当前指定了 ${resolvedWorkflows.length} 个:`
1063
+ + `${resolvedWorkflows.map((w) => `${w.name}(${w.id})`).join(';')}。`
1064
+ + '请先把候选工作流展示给用户确认,再用「名称(ID)」传入单个 workflow 与 filterFields。', { candidates: resolvedWorkflows.map((w, index) => ({ index: index + 1, id: w.id, name: w.name })) });
1065
+ }
1066
+ const targetWorkflow = resolvedWorkflows[0];
1067
+ const workflowLabel = `${targetWorkflow.name || targetWorkflow.id}(${targetWorkflow.id})`;
1068
+ const fields = await fetchWorkflowSearchFields(env, targetWorkflow.id, category, tabid);
1069
+ if (!fields.length) {
1070
+ // V11.3 口径(同 `:7045-7056` 的 `fields_available=False` 分支):该工作流没有可用的
1071
+ // 表单查询字段 → `needClarification:true`,让模型把"这个工作流没有可筛字段"如实告诉用户,
1072
+ // 而不是反复换字段名试探。
1073
+ throw workflowClarificationError('field_meta', workflowLabel, `已匹配到工作流「${workflowLabel}」,但该工作流没有可用的表单查询字段`
1074
+ + '(getListSearchAdWithForm 未返回表单字段),无法生成字段筛选条件。'
1075
+ + '请改为不带 filterFields 查询,或与用户确认工作流是否选对。');
1076
+ }
1077
+ const built = [];
1078
+ for (let index = 0; index < filterFieldsRaw.length; index += 1) {
1079
+ built.push(await resolveFilterFieldItem(env, filterFieldsRaw[index], fields, workflowLabel, index + 1));
1080
+ }
1081
+ if (built.length)
1082
+ formDatas.filterFormDatas = built;
1083
+ }
1084
+ query.searchParams = {
1085
+ formDatas,
1086
+ type: asString(args.conditionType) === 'or' ? 'or' : 'and',
1087
+ initSearch: false,
1088
+ };
1089
+ // 5. 分页 / sort(仅 list)
1090
+ if (mode === 'list') {
1091
+ const current = asInt(args.current) ?? 1;
1092
+ const pageSize = Math.min(asInt(args.pageSize) ?? 10, 100);
1093
+ query.customDisplayPage = { current: Math.max(1, current), size: Math.max(1, pageSize) };
1094
+ }
1095
+ else {
1096
+ delete query.customDisplayPage;
1097
+ delete query.current;
1098
+ delete query.pageSize;
1099
+ }
1100
+ // 6. sortParams(顶层,与 params 平级)
1101
+ const sort = asString(args.sort);
1102
+ if (sort) {
1103
+ const items = [];
1104
+ for (const part of sort.split(',')) {
1105
+ const seg = part.trim();
1106
+ if (!seg.includes(':'))
1107
+ continue;
1108
+ const [name, st] = seg.split(':');
1109
+ const sortType = st.trim().toLowerCase();
1110
+ if (sortType !== 'asc' && sortType !== 'desc')
1111
+ continue;
1112
+ const key = name.trim();
1113
+ const orderKey = /^-?\d+$/.test(key) ? Number(key) : SORT_OPTIONS[key];
1114
+ if (orderKey === undefined)
1115
+ continue;
1116
+ items.push({ orderkey: orderKey, sortType });
1117
+ }
1118
+ if (items.length)
1119
+ query.sortParams = items;
1120
+ }
1121
+ return { query, tabid };
1122
+ }
1123
+ function safeJsonArray(value) {
1124
+ try {
1125
+ const parsed = JSON.parse(value);
1126
+ return Array.isArray(parsed) ? parsed : [];
1127
+ }
1128
+ catch {
1129
+ return [];
1130
+ }
1131
+ }
1132
+ /**
1133
+ * 解析 `filterFields` 入参(对象数组)。
1134
+ *
1135
+ * **不可用 `asStringArray`**——它会把对象元素拍平成 `"[object Object]"`,使字段条件在 CLI 侧
1136
+ * 就被丢弃(旧实现即如此:只能解析为 null → `filterFormDatas` 根本不发出 → 返回未筛选的全量数据)。
1137
+ * 支持:对象数组 / JSON 字符串的数组 / 由 JSON 字符串组成的数组。解析不出对象一律报错,不静默丢弃。
1138
+ */
1139
+ function parseFilterFieldsInput(raw) {
1140
+ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
1141
+ if (raw === undefined || raw === null || raw === '')
1142
+ return [];
1143
+ const out = [];
1144
+ if (typeof raw === 'string') {
1145
+ for (const item of safeJsonArray(raw.trim()))
1146
+ if (isPlainObject(item))
1147
+ out.push(item);
1148
+ }
1149
+ else if (Array.isArray(raw)) {
1150
+ for (const item of raw) {
1151
+ if (isPlainObject(item)) {
1152
+ out.push(item);
1153
+ continue;
1154
+ }
1155
+ if (typeof item === 'string') {
1156
+ for (const parsed of safeJsonArray(item.trim()))
1157
+ if (isPlainObject(parsed))
1158
+ out.push(parsed);
1159
+ }
1160
+ }
1161
+ if (!out.length) {
1162
+ throw workflowValidationError('field_invalid', 'filterFields 必须是对象数组,每项形如 {"fieldName":"字段名(字段ID)","term":"like","value":...};'
1163
+ + `当前数组元素无法解析为对象(收到 ${JSON.stringify(raw).slice(0, 200)})。`);
1164
+ }
1165
+ }
1166
+ else {
1167
+ throw workflowValidationError('field_invalid', 'filterFields 必须是对象数组。');
1168
+ }
1169
+ return out;
1170
+ }
1171
+ // ---------------------------------------------------------------------------
1172
+ // 列表响应归一化(来源:Python normalize_workflow_list_response,L2694)
1173
+ // ---------------------------------------------------------------------------
1174
+ /** isremark < 100 才可提交/退回/转办(来源:Python is_actionable_remark);非数字一律视为不可操作 */
1175
+ function isActionableRemark(value) {
1176
+ const text = value === undefined || value === null ? '' : String(value).trim();
1177
+ if (!/^-?\d+$/.test(text))
1178
+ return false;
1179
+ return Number(text) < 100;
1180
+ }
1181
+ function normalizeListResponse(response, category, categoryName, baseUrl = '') {
1182
+ const record = responseData(response);
1183
+ const data = record?.data && typeof record.data === 'object' ? record.data : {};
1184
+ const records = toArray(record?.data?.displayData ?? record?.displayData ?? record?.data?.list ?? record?.list ?? []);
1185
+ const items = records.map((rec, index) => {
1186
+ const recordObj = rec && typeof rec === 'object' ? rec : {};
1187
+ const isRemarkRaw = pickRaw(recordObj, 'isremark', 'isRemark');
1188
+ const finished = recordObj.finish === true;
1189
+ const flowStatus = pickText(recordObj, 'flowstatusShow', 'flowStatusShow') ||
1190
+ flowStatusText(recordObj.flowStatus) ||
1191
+ pickText(recordObj, 'status') ||
1192
+ (recordObj.draft === true ? '草稿' : '') ||
1193
+ (finished ? '已结束' : '');
1194
+ const stayStartAt = pickText(recordObj, 'receivedatetime', 'receiveDateTime', 'receiveDatetime', 'receiveTime', 'operatedatetime', 'operateDateTime', 'operateDatetime', 'operateTime');
1195
+ const overdue = recordObj.overdue === true && !finished;
1196
+ const badges = [];
1197
+ if (overdue)
1198
+ badges.push('已超时');
1199
+ if (shouldDisplayFlowStatusBadge(flowStatus))
1200
+ badges.push(flowStatus);
1201
+ const uniqueBadges = badges.filter((label, position) => badges.indexOf(label) === position);
1202
+ const item = {
1203
+ index: index + 1,
1204
+ requestId: pickText(recordObj, 'requestId', 'requestid'),
1205
+ userMasterId: pickText(recordObj, 'userMasterId', 'userMasterID', 'secondEmpId'),
1206
+ nodeid: pickText(recordObj, 'nodeid', 'nodeId', 'currentNodeId'),
1207
+ flowName: pickText(recordObj, 'requestnameTitle', 'requestnametitle') ||
1208
+ pickText(recordObj, 'requestname', 'requestName'),
1209
+ workflowId: pickText(recordObj, 'workflowid', 'workflowId'),
1210
+ workflowName: pickText(recordObj, 'workflowname', 'workflowName'),
1211
+ creatorName: pickText(recordObj, 'creatername', 'creatorName', 'sourceUserName'),
1212
+ currentNode: pickText(recordObj, 'currentnode', 'currentNode', 'currentNodeName', 'nodeName'),
1213
+ createdAt: pickText(recordObj, 'createdatetime', 'createDateTime', 'createTime'),
1214
+ flowStatus,
1215
+ requestMark: pickText(recordObj, 'requestmark', 'requestMark', 'requestCode'),
1216
+ isRemark: isRemarkRaw === undefined || isRemarkRaw === null ? '' : String(isRemarkRaw).trim(),
1217
+ canSubmitRejectTransfer: isActionableRemark(isRemarkRaw),
1218
+ stayStartAt,
1219
+ stayDuration: category === 'todo' ? humanizeDurationSince(stayStartAt) : '',
1220
+ finished,
1221
+ overdue,
1222
+ timeoutText: pickText(recordObj, 'userOverTime'),
1223
+ timeoutLabel: overdue ? '超时时间' : '预计超时',
1224
+ requestLevelText: requestLevelText(pickText(recordObj, 'requestlevelShow', 'requestLevelShow', 'requestLevelTitle', 'requestlevelTitle') ||
1225
+ recordObj.requestLevel),
1226
+ badges: uniqueBadges,
1227
+ badgeObjects: buildBadgeObjects(uniqueBadges),
1228
+ };
1229
+ item.cardMeta = buildCardMeta(item, category);
1230
+ return item;
1231
+ });
1232
+ const total = asInt(data?.total ?? record?.total);
1233
+ const hasMore = asBool(data?.hasMore ?? record?.hasMore, false);
1234
+ const current = asInt(data?.current ?? record?.current) ?? 1;
1235
+ const pageSize = asInt(data?.pageSize ?? record?.pageSize) ?? 10;
1236
+ return {
1237
+ type: 'workflowList',
1238
+ category,
1239
+ categoryName,
1240
+ current,
1241
+ pageSize,
1242
+ total,
1243
+ totalReliable: total !== null,
1244
+ hasMore,
1245
+ items,
1246
+ cardItems: items.map((item) => {
1247
+ const card = {
1248
+ index: item.index,
1249
+ title: item.flowName,
1250
+ badges: item.badges,
1251
+ badgeObjects: item.badgeObjects,
1252
+ meta: item.cardMeta,
1253
+ internal: {
1254
+ requestId: item.requestId,
1255
+ userMasterId: item.userMasterId,
1256
+ nodeid: item.nodeid,
1257
+ isRemark: item.isRemark,
1258
+ canSubmitRejectTransfer: item.canSubmitRejectTransfer,
1259
+ overdue: item.overdue,
1260
+ requestLevelText: item.requestLevelText,
1261
+ // 该流程所属工作流(V12.1.34):条目里本来就有(见 items 的 workflowId/workflowName)。
1262
+ // 这里只做结构化回显,不做任何名称→ID 推断。
1263
+ //
1264
+ // ★ V12.1.37:本结构(`cardItems`)已**不再输出给模型**(见 SLIM_INDEX_ITEM_KEYS:
1265
+ // 输出只保留 `cards[]` 的定位/标识/可操作性字段),它现在只在 CLI 内部用于渲染
1266
+ // `finalMarkdown` 与草稿过滤。模型侧要 workflowId 请从 `cards[].workflowId` 取。
1267
+ workflowId: item.workflowId,
1268
+ workflowName: item.workflowName,
1269
+ },
1270
+ };
1271
+ card.url = cardWorkflowOpenUrl(card, baseUrl);
1272
+ card.renderMarkdown = renderCardMarkdown(card);
1273
+ return card;
1274
+ }),
1275
+ };
1276
+ }
1277
+ // ---------------------------------------------------------------------------
1278
+ // 输出体积预算:条目瘦身 + 超线落盘(来源:Python persist_large_search_output /
1279
+ // enforce_workflow_search_stdout_budget,V11.3.104)
1280
+ // ---------------------------------------------------------------------------
1281
+ /** 查询类 stdout 安全线(**美化**口径,与 `createForm` 同规格)。
1282
+ *
1283
+ * 宿主单次直读线实测约 28,000–29,300 B:超线后除开头 2,048 B 预览外**永远看不见**,
1284
+ * 取 24,000 留余量。量这个线必须用 `stdoutBytesOf()`(美化)而不是压缩口径——CLI 实际
1285
+ * 打印的是 `JSON.stringify(v, null, 2)`,压缩口径会低估约 1.8 倍(V12.1.34 的教训)。
1286
+ */
1287
+ const SEARCH_STDOUT_SAFE_BYTES = STDOUT_SAFE_BYTES;
1288
+ /** 输出给模型的条目字段白名单:只保留**机器可读的定位 / 标识 / 可操作性判断**字段。
1289
+ *
1290
+ * `cardMeta`/`badgeObjects`/`badges`/`flowStatus`/`currentNode`/`creatorName`/`stayDuration`/
1291
+ * `timeoutLabel`/`timeoutText`/`requestLevelText`/`createdAt`/`stayStartAt` 都是**渲染用**字段,
1292
+ * 而唯一渲染源是 `finalMarkdown`(卡片文本已含标题链接、状态徽标与「标签:值」行)——保留它们
1293
+ * 等于把同一张卡片存两遍:实测泛化待办查询(20 条)这批字段占 stdout 的 61%。
1294
+ * `userMasterId`/`nodeid` 只在拼 URL 与内部定位时用,模型侧没有消费点。
1295
+ */
1296
+ const SLIM_INDEX_ITEM_KEYS = [
1297
+ 'index',
1298
+ 'requestId',
1299
+ 'requestMark',
1300
+ 'flowName',
1301
+ 'workflowId',
1302
+ 'workflowName',
1303
+ 'canSubmitRejectTransfer',
1304
+ 'isRemark',
1305
+ 'finished',
1306
+ 'overdue',
1307
+ ];
1308
+ /** 条目瘦身投影(内部逻辑继续消费完整条目,只在**输出**时收窄)。 */
1309
+ function slimIndexItem(item) {
1310
+ const slim = {};
1311
+ for (const key of SLIM_INDEX_ITEM_KEYS) {
1312
+ if (key in item)
1313
+ slim[key] = item[key];
1314
+ }
1315
+ return slim;
1316
+ }
1317
+ /** 超线落盘时 stdout 侧保留的形态:移出结构化明细,只留渲染层与元数据。 */
1318
+ function stripHeavyPayload(result) {
1319
+ const kept = { ...result };
1320
+ delete kept.cards;
1321
+ delete kept.cardItems;
1322
+ delete kept.renderMarkdown;
1323
+ if (Array.isArray(kept.groups)) {
1324
+ kept.groups = kept.groups.map((group) => {
1325
+ if (!group || typeof group !== 'object')
1326
+ return group;
1327
+ const next = { ...group };
1328
+ delete next.cardItems;
1329
+ return next;
1330
+ });
1331
+ }
1332
+ if (Array.isArray(kept.specialGroups)) {
1333
+ kept.specialGroups = kept.specialGroups.map((special) => {
1334
+ if (!special || typeof special !== 'object')
1335
+ return special;
1336
+ const next = { ...special };
1337
+ if (Array.isArray(next.slots)) {
1338
+ next.slots = next.slots.map((slot) => {
1339
+ if (!slot || typeof slot !== 'object')
1340
+ return slot;
1341
+ const trimmed = { ...slot };
1342
+ delete trimmed.cardItems;
1343
+ return trimmed;
1344
+ });
1345
+ }
1346
+ return next;
1347
+ });
1348
+ }
1349
+ return kept;
1350
+ }
1351
+ /** 按 UTF-8 字节安全截断(用于"渲染层本身也超预算"的极端兜底)。 */
1352
+ function truncateUtf8Text(text, maxBytes) {
1353
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes)
1354
+ return text;
1355
+ let out = '';
1356
+ let used = 0;
1357
+ for (const char of text) {
1358
+ const size = Buffer.byteLength(char, 'utf8');
1359
+ if (used + size > maxBytes)
1360
+ break;
1361
+ out += char;
1362
+ used += size;
1363
+ }
1364
+ return out;
1365
+ }
1366
+ /** 查询结果超安全线时的收口:完整结果落盘 + stdout 保留渲染层。
1367
+ *
1368
+ * 与 V11.3 的差异(有意为之):V11.3 落盘后只回约 3,200 字的 overview,模型**必须**再读一次
1369
+ * `resultMdFile` 才能渲染;这里让 stdout 直接带上**全量 `finalMarkdown`**——它是模型唯一必须
1370
+ * 渲染的内容、通常只有几 KB,被移出的只是结构化明细。⇒ 正常路径下模型一次就能看全,不需要
1371
+ * 任何二次读取;`resultFile`/`resultMdFile` 只是"要明细时"的续取指针。
1372
+ *
1373
+ * 落盘不可用时**保持全量输出**(宁大不丢数据)并给出提示——与 `todoStat` 同口径。
1374
+ */
1375
+ async function enforceSearchStdoutBudget(result, env, fileName) {
1376
+ const bytes = stdoutBytesOf(result);
1377
+ if (bytes <= SEARCH_STDOUT_SAFE_BYTES)
1378
+ return result;
1379
+ const resultFile = await writeStateFileBestEffort(fileName.json, JSON.stringify(result, null, 2), env.ctx);
1380
+ const resultMdFile = await writeStateFileBestEffort(fileName.md, String(result.finalMarkdown || ''), env.ctx);
1381
+ if (!resultFile) {
1382
+ return {
1383
+ ...result,
1384
+ outputBytes: bytes,
1385
+ largeOutputNotice: '查询结果较大且本地落盘不可用,已按原样返回全量条目明细。',
1386
+ };
1387
+ }
1388
+ const noticeBase = '查询结果较大:完整结果已写入 resultFile(结构化 JSON)与 resultMdFile(纯渲染文本)。'
1389
+ + '渲染直接用下方 finalMarkdown 即可,无需读文件;只有需要完整结构化明细(如逐条 requestId)时'
1390
+ + '才从 resultFile 按需提取(不要把整份读进上下文),并请一次读完:不要用 head/tail 等管道截断'
1391
+ + '(会破坏 JSON,并导致重复执行同一命令)。';
1392
+ const stripped = withLeadingMeta({ resultFile, resultMdFile, persistedResult: true, outputBytes: bytes, largeOutputNotice: noticeBase }, stripHeavyPayload(result));
1393
+ if (stdoutBytesOf(stripped) <= SEARCH_STDOUT_SAFE_BYTES)
1394
+ return stripped;
1395
+ // 极端兜底:渲染层本身也超预算 ⇒ 截断 finalMarkdown,强制走 resultMdFile
1396
+ return {
1397
+ ...stripped,
1398
+ largeOutputNotice: `${noticeBase}注意:本次渲染文本本身也超过单次输出上限,下方 finalMarkdown **已被截断**——`
1399
+ + '必须读取 resultMdFile 取完整 finalMarkdown 后原样渲染,禁止重跑本命令。',
1400
+ finalMarkdown: truncateUtf8Text(String(result.finalMarkdown || ''), 8_000),
1401
+ };
1402
+ }
1403
+ // ---------------------------------------------------------------------------
1404
+ // 序号定位(查询类写索引;查看类读索引)
1405
+ // ---------------------------------------------------------------------------
1406
+ async function saveListIndex(env, normalized) {
1407
+ const items = normalized.items || [];
1408
+ return saveSearchIndex({
1409
+ items,
1410
+ summary: {
1411
+ type: normalized.type,
1412
+ category: normalized.category,
1413
+ total: normalized.total,
1414
+ hasMore: normalized.hasMore,
1415
+ },
1416
+ }, env.ctx);
1417
+ }
1418
+ /** 从 requestId 或 group+index 解析出目标 requestId。索引失效抛 index_stale。 */
1419
+ async function resolveTargetRequestId(env, args) {
1420
+ const explicit = asString(args.requestId);
1421
+ if (explicit)
1422
+ return explicit;
1423
+ const index = asInt(args.index);
1424
+ if (index === undefined) {
1425
+ throw workflowValidationError('locate_required', '必须提供 requestId 或 group+index 定位目标流程');
1426
+ }
1427
+ const group = asString(args.group);
1428
+ const searchIndex = await loadSearchIndex(env.ctx);
1429
+ const item = findIndexedItem(searchIndex, group, index);
1430
+ if (!item || !item.requestId) {
1431
+ throw workflowValidationError('index_stale', '序号定位失败:本地检索索引已失效或该序号不存在,请重新执行查询(workflow.search / workflow.todoStat)后再操作。', { group: group ?? null, index });
1432
+ }
1433
+ return item.requestId;
1434
+ }
1435
+ // ---------------------------------------------------------------------------
1436
+ // workflow.search(来源:cmd_workflow_search / execute_workflow_search_smart)
1437
+ // ---------------------------------------------------------------------------
1438
+ const workflowSearch = {
1439
+ name: 'workflow.search',
1440
+ resource: 'workflow',
1441
+ method: 'search',
1442
+ action: 'workflow.search',
1443
+ summary: '分页查询流程(待办/已办/我发起/全部),支持工作流、表单字段、时间、紧急程度、发起人、标题/编号等条件筛选;支持 plan 批量编排取数',
1444
+ capability: 'search',
1445
+ risk: 'read',
1446
+ inputSchema: schemaOf({
1447
+ category: SCHEMA_CATEGORY,
1448
+ tabid: { type: 'string', description: '细分子类 tabid' },
1449
+ tabName: { type: 'string', description: '细分子类名称' },
1450
+ subCategory: { type: 'string', description: '细分子类名称或 tabid' },
1451
+ keyword: { type: 'array', items: { type: 'string' }, description: '实体关键词(辅助解析)' },
1452
+ creator: { type: 'string', description: '发起人姓名,支持 "名称(ID)";纯名称多命中返回澄清候选,0 命中报错(对齐 V11.3)' },
1453
+ creatorDept: { type: 'string', description: '发起人部门;纯名称多命中返回澄清候选,0 命中报错' },
1454
+ creatorSubcom: { type: 'string', description: '发起人分部;纯名称多命中返回澄清候选,0 命中报错' },
1455
+ workflowType: { type: 'string', description: '工作流类型;纯名称多命中返回澄清候选,0 命中报错' },
1456
+ workflow: { type: 'array', items: { type: 'string' }, description: '工作流条件,多个 or;支持 "名称(ID)";纯名称未匹配到/匹配到多个且无全名精确匹配时返回澄清(候选见 candidates),确认后用「名称(ID)」重发' },
1457
+ workflowTerm: { type: 'string', enum: ['in', 'notin'] },
1458
+ requestName: { type: 'string', description: '流程标题 like 模糊' },
1459
+ requestNameTerm: { type: 'string', enum: ['in', 'notin', 'eq', 'ne'] },
1460
+ requestMark: { type: 'string', description: '流程编号 like 模糊' },
1461
+ requestMarkTerm: { type: 'string', enum: ['in', 'notin', 'eq', 'ne'] },
1462
+ requestLevel: { type: 'string', description: '紧急程度名称(CLI 按当前环境等级列表转 ID);纯名称多命中返回澄清候选,0 命中报错并列出可选等级(对齐 V11.3)' },
1463
+ requestId: { type: 'string', description: '按 requestId 精确过滤,逗号分隔' },
1464
+ exRequestId: { type: 'string', description: '排除 requestId,逗号分隔' },
1465
+ nodeId: { type: 'string', description: '当前节点 ID' },
1466
+ nodeIdTerm: { type: 'string', enum: ['in', 'notin'] },
1467
+ flowStatus: { type: 'string', description: '流程状态' },
1468
+ flowStatusTerm: { type: 'string', enum: ['in', 'notin'] },
1469
+ createTime: { type: 'string', description: TIME_DESCRIPTIONS.createTime },
1470
+ operateTime: { type: 'string', description: TIME_DESCRIPTIONS.operateTime },
1471
+ receiveTime: { type: 'string', description: TIME_DESCRIPTIONS.receiveTime },
1472
+ finishTime: { type: 'string', description: TIME_DESCRIPTIONS.finishTime },
1473
+ filterFields: {
1474
+ type: 'array',
1475
+ items: { type: 'object' },
1476
+ description: '表单字段筛选条件:[{"fieldName":"字段名(字段ID)","term":"...","value":...}];必须同时传且只传一个 workflow,'
1477
+ + 'fieldName 会按该工作流真实表单字段解析(解析不出唯一字段时返回澄清并列出候选)',
1478
+ },
1479
+ sort: { type: 'string', description: '"排序方式:asc|desc",多组逗号分隔' },
1480
+ current: { type: 'integer', minimum: 1, default: 1 },
1481
+ pageSize: SCHEMA_PAGE_SIZE,
1482
+ conditionType: { type: 'string', enum: ['and', 'or'], default: 'and' },
1483
+ totalOnly: { type: 'boolean', default: false },
1484
+ mode: { type: 'string', enum: ['auto', 'legacy', 'smart'], default: 'auto', description: '执行模式强制覆盖,仅回归验证用;默认按 OA 版本自动路由' },
1485
+ batchType: {
1486
+ type: 'string',
1487
+ enum: ['submit', 'urge', 'read', 'share', 'forward', 'reject'],
1488
+ description: '过滤允许对应批量操作的数据(批量操作前置取数);配合 pageSize=100',
1489
+ },
1490
+ planJson: { type: 'object', description: 'V11.3.67 批量编排模式 JSON({category, specialGroups?, groups:[{groupName,collapsed?,condition}]})' },
1491
+ queryJson: { type: 'object', description: '已生成的完整查询 JSON(跳过 build-query)' },
1492
+ }),
1493
+ handler: async (args, env) => {
1494
+ const plan = args.planJson;
1495
+ if (plan && typeof plan === 'object') {
1496
+ // plan 批量编排仅智能分组模式支持(来源:Python cmd_workflow_search_plan 首行的
1497
+ // resolve_workflow_search_mode 门禁)——该模式下 todoStat 的统计口径才成立;低版本 OA 上
1498
+ // 直接拒绝,并引导改用单条查询,避免静默按错口径分组。--mode smart 仍可强制(回归验证用)。
1499
+ const planMode = await resolveSearchMode(env, args);
1500
+ if (planMode !== 'smart') {
1501
+ throw workflowValidationError('plan_requires_smart_mode', `plan 批量编排模式仅支持智能分组模式(OA ≥ ${formatOaVersion(SMART_GROUPING_MIN_OA_VERSION)});低版本请改用单条 workflow.search`, { retryAllowed: false, renderMode: planMode });
1502
+ }
1503
+ // 顶层 pageSize 即 plan 的展示窗口 page_cap(来源:Python cmd_workflow_search_plan 的 page_cap)
1504
+ return executePlanSearch(env, plan, asInt(args.pageSize) ?? 10);
1505
+ }
1506
+ const category = (asString(args.category) || 'todo');
1507
+ const searchMode = await resolveSearchMode(env, args);
1508
+ const batchType = asString(args.batchType);
1509
+ const totalOnly = asBool(args.totalOnly, false);
1510
+ // legacy 待办查询走 CLI 侧分组(V11.2 语义;来源:Python execute_workflow_search_legacy 的
1511
+ // grouping_active 分支):未显式指定子分类、非 total-only、非批量取数时按「待处理/超时/紧急」
1512
+ // 分组取数并输出分组统计。smart 模式不做 CLI 侧分组(分组由模型按 countDetails 规划)。
1513
+ if (searchMode === 'legacy' && category === 'todo' && !batchType && !totalOnly && !hasExplicitTab(args)) {
1514
+ return executeLegacyGroupedTodoSearch(env, args);
1515
+ }
1516
+ const { query, tabid } = await buildSearchQuery(env, { ...args, searchMode }, 'list');
1517
+ const prefix = ENDPOINT_PREFIX[category];
1518
+ let listKey = `${prefix}_LIST`;
1519
+ let totalKey = `${prefix}_TOTAL`;
1520
+ if (batchType) {
1521
+ const suffix = batchType.toUpperCase();
1522
+ // 六类批量操作数据查询(来源:Python batch_endpoint_keys:
1523
+ // submit/urge/read/share/forward/reject)——缺 share/forward/reject 时
1524
+ // 批量共享/转发/退回的"可操作数据"取数链路断裂。
1525
+ if (['SUBMIT', 'URGE', 'READ', 'SHARE', 'FORWARD', 'REJECT'].includes(suffix)) {
1526
+ listKey = `TODO_LIST_BATCH_${suffix}`;
1527
+ totalKey = `TODO_TOTAL_BATCH_${suffix}`;
1528
+ }
1529
+ }
1530
+ const totalBody = JSON.parse(JSON.stringify(query));
1531
+ // legacy 通用待办(未显式指定子分类):表头总数按「全部(0)」口径取数(来源:Python
1532
+ // execute_workflow_search_legacy 的 generic_todo_scope 分支)——与分组模式下的 all_total 对齐。
1533
+ if (searchMode === 'legacy' && category === 'todo' && !hasExplicitTab(args)) {
1534
+ totalBody.params = { ...(totalBody.params || {}), tabid: DEFAULT_TABID.todo };
1535
+ }
1536
+ delete totalBody.customDisplayPage;
1537
+ const session = await env.host.getSession();
1538
+ // 超时/紧急探查仅在 legacy(低版本)待办查询生效(来源:Python execute_workflow_search_legacy)
1539
+ const probeEnabled = category === 'todo' && !batchType && searchMode === 'legacy';
1540
+ // 待阅探查:待办查询且当前不在「待阅(5)」子分类时补一路待阅总数
1541
+ // (来源:Python execute_workflow_search_legacy 的 wait_read_future)
1542
+ const waitReadEnabled = probeEnabled && tabid !== '5';
1543
+ const waitReadBody = JSON.parse(JSON.stringify(query));
1544
+ waitReadBody.params = { ...(waitReadBody.params || {}), tabid: '5' };
1545
+ delete waitReadBody.customDisplayPage;
1546
+ const responses = await Promise.all([
1547
+ env.host.workflowRequest({ method: 'POST', path: WORKFLOW_ENDPOINTS[totalKey], operation: 'workflow.search.total', body: totalBody }),
1548
+ totalOnly
1549
+ ? Promise.resolve(null)
1550
+ : env.host.workflowRequest({ method: 'POST', path: WORKFLOW_ENDPOINTS[listKey], operation: 'workflow.search', body: query }),
1551
+ probeEnabled ? probeTodoOverdue(env, query, totalKey) : Promise.resolve(null),
1552
+ probeEnabled ? probeTodoUrgent(env, query, totalKey) : Promise.resolve(null),
1553
+ waitReadEnabled
1554
+ ? env.host.workflowRequest({ method: 'POST', path: WORKFLOW_ENDPOINTS.TODO_TOTAL, operation: 'workflow.search.probe.waitRead', body: waitReadBody })
1555
+ : Promise.resolve(null),
1556
+ ]);
1557
+ const totalResponse = responses[0];
1558
+ const listResponse = responses[1];
1559
+ const probes = { overdue: responses[2], urgent: responses[3] };
1560
+ const total = extractTotal(totalResponse);
1561
+ if (total === null) {
1562
+ throw workflowValidationError('workflow_total_invalid', '总数接口未返回有效数据,本次已停止返回,避免把接口异常误判为 0 条。', { retryAllowed: false });
1563
+ }
1564
+ // 探查失败必须硬失败:把接口异常当成「无超时/无紧急」会让用户与模型都误判为 0 条
1565
+ // (来源:Python require_probe_success + require_http_result)
1566
+ if (probeEnabled) {
1567
+ requireProbeSuccess(probes.overdue, 'TODO_OVERDUE_PROBE', '超时待办探查');
1568
+ requireProbeSuccess(probes.urgent, 'TODO_URGENT_PROBE', '紧急待办探查');
1569
+ }
1570
+ let waitReadTotal = null;
1571
+ if (waitReadEnabled) {
1572
+ waitReadTotal = extractTotal(responses[4]);
1573
+ if (waitReadTotal === null) {
1574
+ throw workflowValidationError('wait_read_total_invalid', '待阅总数接口未返回有效数据,本次已停止返回,避免把接口异常误判为 0 条。', { retryAllowed: false });
1575
+ }
1576
+ }
1577
+ if (totalOnly) {
1578
+ return workflowSuccess('workflow.search', {
1579
+ type: 'workflowSearch',
1580
+ renderMode: searchMode,
1581
+ category,
1582
+ categoryName: CATEGORY_NAMES[category],
1583
+ total,
1584
+ finalMarkdown: buildSearchFinalMarkdown(null, probes, total, category, true, searchMode === 'smart', waitReadTotal),
1585
+ });
1586
+ }
1587
+ const normalized = normalizeListResponse(listResponse, category, CATEGORY_NAMES[category], session.baseUrl ?? '');
1588
+ normalized.total = total;
1589
+ const index = await saveListIndex(env, normalized);
1590
+ return workflowSuccess('workflow.search', await enforceSearchStdoutBudget({
1591
+ type: 'workflowSearch',
1592
+ renderMode: searchMode,
1593
+ category,
1594
+ categoryName: CATEGORY_NAMES[category],
1595
+ total,
1596
+ hasMore: normalized.hasMore,
1597
+ current: normalized.current,
1598
+ pageSize: normalized.pageSize,
1599
+ // 条目只输出机器可读的定位/标识/可操作性字段——渲染一律用 finalMarkdown,
1600
+ // 不再回显渲染字段(此前 cards / cardItems / renderMarkdown 三份互相重复)。
1601
+ cards: normalized.items.map(slimIndexItem),
1602
+ finalMarkdown: buildSearchFinalMarkdown(normalized, probes, total, category, false, searchMode === 'smart', waitReadTotal),
1603
+ queryId: index.queryId,
1604
+ sessionMap: { queryId: index.queryId, itemCount: normalized.items.length },
1605
+ }, env, { json: 'last_search.json', md: 'last_search.md' }));
1606
+ },
1607
+ };
1608
+ // ---------------------------------------------------------------------------
1609
+ // workflow.search 执行模式路由与最终 Markdown(来源:Python resolve_workflow_search_mode /
1610
+ // build_workflow_search_final_markdown / todo_probe_notice)
1611
+ // ---------------------------------------------------------------------------
1612
+ /** 智能分组基线版本(来源:Python _SMART_GROUPING_MIN_OA_VERSION) */
1613
+ const SMART_GROUPING_MIN_OA_VERSION = [10, 0, 9909, 1];
1614
+ /** 把版本元组格式化为 OA 习惯的四段式(尾两段补零:10.0.9909.01)。错误提示一律由常量派生,避免文案与判定漂移。 */
1615
+ function formatOaVersion(tuple) {
1616
+ const [a, b, c, d] = tuple;
1617
+ return `${a}.${b}.${c}.${String(d).padStart(2, '0')}`;
1618
+ }
1619
+ function oaVersionTuple(version) {
1620
+ const numbers = String(version ?? '')
1621
+ .split(/[^0-9]+/)
1622
+ .filter(Boolean)
1623
+ .map((part) => Number(part));
1624
+ return [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0, numbers[3] ?? 0];
1625
+ }
1626
+ function isVersionAtLeast(actual, min) {
1627
+ for (let i = 0; i < 4; i += 1) {
1628
+ if (actual[i] !== min[i])
1629
+ return actual[i] > min[i];
1630
+ }
1631
+ return true;
1632
+ }
1633
+ /** 解析查询执行模式:版本 ≥ 基线走 smart;版本未知/运行时不可用 → legacy 兜底 */
1634
+ async function resolveSearchMode(env, args) {
1635
+ const forced = (asString(args.mode) ?? '').toLowerCase();
1636
+ if (forced === 'smart' || forced === 'legacy')
1637
+ return forced;
1638
+ try {
1639
+ const session = await env.host.getSession();
1640
+ const version = asString(session.version) ?? '';
1641
+ if (version && isVersionAtLeast(oaVersionTuple(version), SMART_GROUPING_MIN_OA_VERSION))
1642
+ return 'smart';
1643
+ }
1644
+ catch (error) {
1645
+ // 认证/网络错误不得被"版本未知 → legacy"吞掉(否则登录过期会被伪装成低版本模式)
1646
+ rethrowAuthOrNetworkError(error);
1647
+ // 其余情形(会话信息取不到)按 legacy 兜底
1648
+ }
1649
+ return 'legacy';
1650
+ }
1651
+ /** 超时/紧急探查提示(来源:Python todo_probe_notice) */
1652
+ function todoProbeNotice(probes) {
1653
+ const parts = [];
1654
+ const overdueTotal = asInt(probes?.overdue?.total) ?? 0;
1655
+ if (overdueTotal > 0)
1656
+ parts.push(`${overdueTotal} 条超时待办`);
1657
+ const urgentTotal = asInt(probes?.urgent?.total) ?? 0;
1658
+ if (urgentTotal > 0) {
1659
+ const levelName = asString(probes?.urgent?.level?.levelName) ?? '紧急';
1660
+ parts.push(`${urgentTotal} 条${levelName}待办`);
1661
+ }
1662
+ if (!parts.length)
1663
+ return '';
1664
+ return `另外,该条件下有${parts.join('、')},建议优先关注。`;
1665
+ }
1666
+ /** workflow.search 最终 Markdown(来源:Python build_workflow_search_final_markdown) */
1667
+ function buildSearchFinalMarkdown(normalized, probes, total, category, totalOnly, smartMode, waitReadTotal = null) {
1668
+ const label = CATEGORY_NAMES[category];
1669
+ const lines = [];
1670
+ if (totalOnly) {
1671
+ lines.push(`共 ${total} 条${label}。`);
1672
+ }
1673
+ else if (smartMode) {
1674
+ // smart 模式:不输出顶部统计行——只渲染纯卡片列表,分组名称与汇总由模型书写
1675
+ }
1676
+ else {
1677
+ const current = asInt(normalized?.current) ?? 1;
1678
+ const visible = (normalized?.cardItems || []).length;
1679
+ lines.push(`共 ${total} 条${label},当前显示第 ${current} 页(${visible} 条)。`);
1680
+ }
1681
+ if (!totalOnly) {
1682
+ const notice = todoProbeNotice(probes);
1683
+ if (notice)
1684
+ lines.push(notice);
1685
+ // 待阅提示(来源:Python build_workflow_search_final_markdown 的 wait_read_total 分支)
1686
+ if (asInt(waitReadTotal) !== undefined && asInt(waitReadTotal) > 0) {
1687
+ lines.push(`另有 ${asInt(waitReadTotal)} 条待阅流程。`);
1688
+ }
1689
+ }
1690
+ const body = normalized ? renderListMarkdown(normalized) : '';
1691
+ if (body)
1692
+ lines.push(body);
1693
+ return lines.filter(Boolean).join('\n\n');
1694
+ }
1695
+ /**
1696
+ * 认证/网络类错误必须**原样向上抛**,不得被包装成探查失败或总数降级。
1697
+ *
1698
+ * 反例(实测 2026-09-15):登录态过期时 `TODO_URGENT_PROBE` 抛的是
1699
+ * `type=authentication, subtype=session_expired`,但被包装成 `todo_probe_invalid`
1700
+ * (`type=validation`「接口未返回有效数据」)后,模型与用户会把"登录过期"误判为
1701
+ * "数据问题",既不会引导重新登录、也看不出真实原因(真实原因只能到 `details.detail` 里找)。
1702
+ * 对照:同环境的已办查询(不走探查)正确返回 `authentication/session_expired`。
1703
+ *
1704
+ * 因此只放行这两类错误,其余错误(业务错误、协议异常等)仍按原逻辑处理。
1705
+ */
1706
+ export function rethrowAuthOrNetworkError(error) {
1707
+ const type = error?.type;
1708
+ if (type === 'authentication' || type === 'network')
1709
+ throw error;
1710
+ }
1711
+ /**
1712
+ * 探查失败必须硬失败(来源:Python require_probe_success / require_http_result)。
1713
+ * 此前探查异常被静默吞成 `{error:true,total:null}`,会被当成「无超时/无紧急」——
1714
+ * 与「0 计数不体现」的渲染红线叠加后,用户与模型都会把接口异常误判为 0 条。
1715
+ */
1716
+ function requireProbeSuccess(probe, label, title) {
1717
+ if (!probe || typeof probe !== 'object' || !probe.error)
1718
+ return;
1719
+ throw workflowValidationError('todo_probe_invalid', `${title}(${label})接口未返回有效数据,本次已停止返回,避免把接口异常误判为无超时/无紧急。`, { retryAllowed: false, probe: label, detail: asString(probe.errorMessage) ?? null });
1720
+ }
1721
+ // ---------------------------------------------------------------------------
1722
+ // legacy(低版本模式)待办分组查询
1723
+ //
1724
+ // 来源:Python `execute_grouped_todo_search` / `normalize_todo_group_list` /
1725
+ // `combine_todo_group_results` / `build_grouped_todo_summary_markdown`
1726
+ // (V11.2 查询逻辑,V11.3 由 legacy 分支沿用)。恢复版本门槛后(阈值 10.0.9909.01),
1727
+ // 低版本 OA 环境将长期走这条分支,故必须与 V11.3 对齐。
1728
+ //
1729
+ // 未移植项(有意为之):V11.3 的 `frequentPreference` / `batchSuggestions` 两路依赖
1730
+ // **结构化记忆偏好**(displayPreferences.groupMode/groupOrder、queryPreferences、
1731
+ // batchApprovalPreferences)——该记忆上下文在 V12 已改为由模型经 `workflow.memoryPrompt`
1732
+ // 读取并自行应用,CLI 侧不再解析记忆提示词。默认分组顺序与 V11.3 在「无记忆偏好」时完全一致。
1733
+ // ---------------------------------------------------------------------------
1734
+ /** 是否显式指定了待办子分类(来源:Python explicit_tab 判定)。 */
1735
+ function hasExplicitTab(args) {
1736
+ return Boolean(asString(args.subCategory) || asString(args.tabName) ||
1737
+ (args.tabid !== undefined && String(args.tabid).trim() !== ''));
1738
+ }
1739
+ /** 复制查询并覆写 tabid(可选覆写展示窗口大小)(来源:Python todo_query_for_tab)。 */
1740
+ function todoQueryForTab(query, tabid, pageSize) {
1741
+ const clone = JSON.parse(JSON.stringify(query));
1742
+ clone.params = { ...(clone.params || {}), tabid };
1743
+ if (pageSize !== undefined) {
1744
+ clone.customDisplayPage = {
1745
+ ...(clone.customDisplayPage || {}),
1746
+ current: Math.max(1, asInt(clone.customDisplayPage?.current) ?? 1),
1747
+ size: Math.max(1, Math.min(pageSize, 100)),
1748
+ };
1749
+ }
1750
+ return clone;
1751
+ }
1752
+ /** 待办 tabid → 子分类名(分组标题用;来源:WORKFLOW_CATEGORIES.tabs 反查)。 */
1753
+ function todoTabName(tabid) {
1754
+ for (const [name, value] of Object.entries(SUB_CATEGORY_TABS.todo)) {
1755
+ if (value === String(tabid))
1756
+ return name;
1757
+ }
1758
+ return '待处理';
1759
+ }
1760
+ /** 取一组待办明细(list 接口)并按请求窗口归一化(来源:Python normalize_todo_group_list)。 */
1761
+ async function fetchLegacyTodoGroupList(env, query, total, baseUrl) {
1762
+ const response = await env.host.workflowRequest({
1763
+ method: 'POST',
1764
+ path: WORKFLOW_ENDPOINTS.TODO_LIST,
1765
+ operation: 'workflow.search.group.list',
1766
+ body: query,
1767
+ });
1768
+ const normalized = normalizeListResponse(response, 'todo', CATEGORY_NAMES.todo, baseUrl);
1769
+ const window = (query.customDisplayPage || {});
1770
+ normalized.current = asInt(window.current) ?? normalized.current;
1771
+ normalized.pageSize = asInt(window.size) ?? normalized.pageSize;
1772
+ normalized.total = total;
1773
+ normalized.hasMore = (asInt(normalized.pageSize) ?? 0) * (asInt(normalized.current) ?? 1) < total;
1774
+ return normalized;
1775
+ }
1776
+ /** 跨组去重:剔除已被更高优先级分组展示过的流程(来源:Python filter_group_normalized_to_unseen)。 */
1777
+ function filterGroupToUnseen(normalized, seenRequestIds) {
1778
+ const cards = (normalized.cardItems || []).slice();
1779
+ const items = (normalized.items || []).slice();
1780
+ const itemByRid = new Map();
1781
+ for (const item of items) {
1782
+ const rid = asString(item?.requestId);
1783
+ if (rid)
1784
+ itemByRid.set(rid, item);
1785
+ }
1786
+ const keptCards = [];
1787
+ const keptItems = [];
1788
+ for (const card of cards) {
1789
+ const rid = asString(card?.internal?.requestId) ?? '';
1790
+ if (!rid || seenRequestIds.has(rid))
1791
+ continue;
1792
+ seenRequestIds.add(rid);
1793
+ keptCards.push(card);
1794
+ const item = itemByRid.get(rid);
1795
+ if (item)
1796
+ keptItems.push(item);
1797
+ }
1798
+ normalized.cardItems = keptCards;
1799
+ normalized.items = keptItems;
1800
+ }
1801
+ /**
1802
+ * 合并各分组(来源:Python combine_todo_group_results)。
1803
+ *
1804
+ * 每个分组的序号**独立从 1 开始**——全局递增会让「待处理」组序号从紧急组之后起编,
1805
+ * 与分组标题里展示的序号对不上。
1806
+ */
1807
+ function combineLegacyTodoGroups(groupResults) {
1808
+ const combinedCards = [];
1809
+ const combinedItems = [];
1810
+ const indexGroups = {};
1811
+ const markdownParts = [];
1812
+ const groupPages = [];
1813
+ for (const group of groupResults) {
1814
+ const normalized = group.normalized || {};
1815
+ const cards = normalized.cardItems || [];
1816
+ const items = normalized.items || [];
1817
+ const itemByRid = new Map();
1818
+ for (const item of items) {
1819
+ const rid = asString(item?.requestId);
1820
+ if (rid)
1821
+ itemByRid.set(rid, item);
1822
+ }
1823
+ const current = asInt(normalized.current) ?? 1;
1824
+ const groupIndexItems = [];
1825
+ let groupIndex = 1;
1826
+ for (const card of cards) {
1827
+ const rid = asString(card?.internal?.requestId) ?? '';
1828
+ card.index = groupIndex;
1829
+ card.renderMarkdown = renderCardMarkdown(card);
1830
+ const item = itemByRid.get(rid);
1831
+ if (item) {
1832
+ item.index = groupIndex;
1833
+ combinedItems.push(item);
1834
+ groupIndexItems.push(item);
1835
+ }
1836
+ combinedCards.push(card);
1837
+ groupIndex += 1;
1838
+ }
1839
+ indexGroups[group.kind] = { kind: group.kind, label: group.label, items: groupIndexItems };
1840
+ const displayed = cards.length;
1841
+ if (group.total > 0) {
1842
+ markdownParts.push(`### ${group.label}(共 ${group.total} 条,第 ${current} 页显示 ${displayed} 条)`);
1843
+ markdownParts.push(displayed
1844
+ ? cards.map((card) => String(card.renderMarkdown || '')).join('\n\n---\n\n')
1845
+ : '本页记录已在更高优先级分组中展示。');
1846
+ }
1847
+ groupPages.push({ kind: group.kind, label: group.label, total: group.total, current, displayed });
1848
+ }
1849
+ return {
1850
+ cards: combinedCards,
1851
+ items: combinedItems,
1852
+ indexGroups,
1853
+ groupPages,
1854
+ renderMarkdown: markdownParts.filter(Boolean).join('\n\n'),
1855
+ };
1856
+ }
1857
+ /**
1858
+ * 通用待办顶部汇总(来源:Python build_grouped_todo_summary_markdown)。
1859
+ *
1860
+ * 分页数量只留在各分组标题里,顶部汇总只说总量与构成;记忆偏好尾句未移植(见本节说明)。
1861
+ */
1862
+ function buildGroupedTodoSummaryMarkdown(total, summary) {
1863
+ const personalTotal = asInt(summary.personalTotal) ?? 0;
1864
+ const overdueTotal = asInt(summary.overdueTotal) ?? 0;
1865
+ const urgentTotal = asInt(summary.urgentTotal) ?? 0;
1866
+ const sharedTotal = asInt(summary.sharedTotal) ?? 0;
1867
+ const sharedTotals = {
1868
+ forward: asInt(summary.forwardTotal) ?? 0,
1869
+ cc: asInt(summary.ccTotal) ?? 0,
1870
+ circulate: asInt(summary.circulateTotal) ?? 0,
1871
+ };
1872
+ const sharedLabels = { forward: '转发', cc: '抄送', circulate: '传阅' };
1873
+ const priorityParts = [];
1874
+ if (overdueTotal > 0)
1875
+ priorityParts.push(`${overdueTotal} 条为超时流程`);
1876
+ if (urgentTotal > 0)
1877
+ priorityParts.push(`${urgentTotal} 条为紧急流程`);
1878
+ const categories = [];
1879
+ if (personalTotal > 0) {
1880
+ const suffix = priorityParts.length ? `(其中 ${priorityParts.join('、')},建议优先关注)` : '';
1881
+ categories.push({ label: '需您亲自处理的流程', value: personalTotal, suffix });
1882
+ }
1883
+ if (sharedTotal > 0) {
1884
+ const activeNames = ['forward', 'cc', 'circulate']
1885
+ .filter((kind) => (sharedTotals[kind] ?? 0) > 0)
1886
+ .map((kind) => sharedLabels[kind]);
1887
+ let sharedLabel;
1888
+ if (activeNames.length === 1) {
1889
+ sharedLabel = `${activeNames[0]}给您的流程`;
1890
+ }
1891
+ else if (activeNames.length > 1) {
1892
+ sharedLabel = `${activeNames.slice(0, -1).join('、')}和${activeNames[activeNames.length - 1]}给您的流程`;
1893
+ }
1894
+ else {
1895
+ sharedLabel = '转发、抄送或传阅给您的流程';
1896
+ }
1897
+ categories.push({ label: sharedLabel, value: sharedTotal, suffix: '' });
1898
+ }
1899
+ if (categories.length === 1) {
1900
+ return `共有 ${total} 条流程待办,均为${categories[0].label}${categories[0].suffix}。`;
1901
+ }
1902
+ if (categories.length > 1) {
1903
+ const clauses = categories.map((item) => `${item.label}共 ${item.value} 条${item.suffix}`);
1904
+ return `共有 ${total} 条流程待办:${clauses.join(',')}。`;
1905
+ }
1906
+ return `共有 ${total} 条流程待办。`;
1907
+ }
1908
+ /** legacy 待办分组查询(来源:Python execute_grouped_todo_search)。 */
1909
+ async function executeLegacyGroupedTodoSearch(env, args) {
1910
+ const { query, tabid } = await buildSearchQuery(env, { ...args, searchMode: 'legacy' }, 'list');
1911
+ const session = await env.host.getSession();
1912
+ const baseUrl = session.baseUrl ?? '';
1913
+ const mainSize = Math.max(1, Math.min(asInt(args.pageSize) ?? 10, 100));
1914
+ const auxSize = Math.min(mainSize, 5);
1915
+ const mainTabid = tabid || SUB_CATEGORY_TABS.todo['待处理'];
1916
+ const mainQuery = todoQueryForTab(query, mainTabid, mainSize);
1917
+ const personalQuery = todoQueryForTab(query, '7', mainSize);
1918
+ const allQuery = todoQueryForTab(query, '0', mainSize);
1919
+ const waitReadQuery = todoQueryForTab(query, '5', auxSize);
1920
+ const forwardQuery = todoQueryForTab(query, '4', auxSize);
1921
+ const ccQuery = todoQueryForTab(query, '9', auxSize);
1922
+ const circulateQuery = todoQueryForTab(query, '11', auxSize);
1923
+ const overdueQuery = todoQueryForTab(query, '6', auxSize);
1924
+ // 紧急探查:主查询已带 requestlevel 条件时不再探查(紧急是「待处理」子集,不随展示页签漂移)
1925
+ const hasRequestLevel = Boolean(query?.searchParams?.formDatas?.requestlevel);
1926
+ let urgentQuery = null;
1927
+ let urgentLevelName = '紧急';
1928
+ if (!hasRequestLevel) {
1929
+ let level = null;
1930
+ try {
1931
+ level = await highestRequestLevelProbe(env);
1932
+ }
1933
+ catch (error) {
1934
+ rethrowAuthOrNetworkError(error);
1935
+ requireProbeSuccess({ error: true, errorMessage: errorText(error) }, 'TODO_URGENT_PROBE', '紧急待办探查');
1936
+ }
1937
+ if (level) {
1938
+ urgentLevelName = level.levelName || '紧急';
1939
+ urgentQuery = todoQueryForTab(query, '7', auxSize);
1940
+ urgentQuery.searchParams = urgentQuery.searchParams || {};
1941
+ urgentQuery.searchParams.formDatas = urgentQuery.searchParams.formDatas || {};
1942
+ urgentQuery.searchParams.formDatas.requestlevel = [{ content: level.id }];
1943
+ }
1944
+ }
1945
+ // 阶段一:各分组总数(独立总数接口;列表接口 total 与组内条数不同口径,不可当组总数用)
1946
+ const totalJobs = {
1947
+ all_total: planTotalCount(env, allQuery, 'todo'),
1948
+ main_total: planTotalCount(env, mainQuery, 'todo'),
1949
+ wait_read_total: planTotalCount(env, waitReadQuery, 'todo'),
1950
+ forward_total: planTotalCount(env, forwardQuery, 'todo'),
1951
+ cc_total: planTotalCount(env, ccQuery, 'todo'),
1952
+ circulate_total: planTotalCount(env, circulateQuery, 'todo'),
1953
+ overdue_total: planTotalCount(env, overdueQuery, 'todo'),
1954
+ };
1955
+ if (mainTabid !== '7')
1956
+ totalJobs.personal_total = planTotalCount(env, personalQuery, 'todo');
1957
+ if (urgentQuery)
1958
+ totalJobs.urgent_total = planTotalCount(env, urgentQuery, 'todo');
1959
+ const totalPairs = await Promise.all(Object.entries(totalJobs).map(async ([key, task]) => [key, await task]));
1960
+ const totals = {};
1961
+ for (const [key, value] of totalPairs)
1962
+ totals[key] = value;
1963
+ if (mainTabid === '7')
1964
+ totals.personal_total = totals.main_total ?? null;
1965
+ const requiredTotals = [
1966
+ 'all_total', 'personal_total', 'main_total', 'wait_read_total',
1967
+ 'forward_total', 'cc_total', 'circulate_total', 'overdue_total',
1968
+ ];
1969
+ if (requiredTotals.some((key) => totals[key] === null || totals[key] === undefined)) {
1970
+ throw workflowValidationError('workflow_group_total_invalid', '待办分组查询未能取得完整总数,本次已停止返回,避免展示不完整分组。', { retryAllowed: false });
1971
+ }
1972
+ const allTotal = totals.all_total;
1973
+ const mainTotal = totals.main_total;
1974
+ const overdueTotal = totals.overdue_total;
1975
+ const urgentTotal = totals.urgent_total ?? 0;
1976
+ // 回退:未指定子分类(默认待处理)且待处理无数据时,改查「全部」——此时只给汇总、
1977
+ // 没有任何可看列表,而转发/抄送/传阅类待办同样是有效内容。待处理为 0 时紧急(待处理子集)
1978
+ // 必为 0;条目卡片自带超时徽标,故回退后不再单列超时/紧急组,避免与全部组重复。
1979
+ const fallbackToAll = mainTabid === '7' && mainTotal === 0 && allTotal > 0;
1980
+ // 超时/紧急列表仅在对应分组有货时取(无货时 V11.3 也不消费该响应)
1981
+ const showOverdue = !fallbackToAll && mainTabid !== '6' && overdueTotal > 0;
1982
+ const showUrgent = !fallbackToAll && Boolean(urgentQuery) && urgentTotal > 0;
1983
+ // 阶段二:明细(主列表 + 有货的特殊组列表);回退时主列表即「全部」
1984
+ const mainListQuery = fallbackToAll ? allQuery : mainQuery;
1985
+ const mainListTotal = fallbackToAll ? allTotal : mainTotal;
1986
+ const listJobs = {
1987
+ main_list: fetchLegacyTodoGroupList(env, mainListQuery, mainListTotal, baseUrl),
1988
+ };
1989
+ if (showOverdue)
1990
+ listJobs.overdue_list = fetchLegacyTodoGroupList(env, overdueQuery, overdueTotal, baseUrl);
1991
+ if (showUrgent) {
1992
+ listJobs.urgent_list = fetchLegacyTodoGroupList(env, urgentQuery, urgentTotal, baseUrl);
1993
+ }
1994
+ const listPairs = await Promise.all(Object.entries(listJobs).map(async ([key, task]) => [key, await task]));
1995
+ const lists = {};
1996
+ for (const [key, value] of listPairs)
1997
+ lists[key] = value;
1998
+ // 分组顺序:紧急 → 超时 → 待处理(V11.3 todo_group_order 在无记忆偏好时的稳定顺序)
1999
+ const groups = [];
2000
+ if (showUrgent) {
2001
+ groups.push({ kind: 'urgent', label: `${urgentLevelName}待办`, total: urgentTotal, normalized: lists.urgent_list });
2002
+ }
2003
+ if (showOverdue) {
2004
+ groups.push({ kind: 'overdue', label: '超时待办', total: overdueTotal, normalized: lists.overdue_list });
2005
+ }
2006
+ // 回退时主组即「全部」(待处理无数据时不再展示空的待处理组)
2007
+ groups.push({
2008
+ kind: 'pending',
2009
+ label: `${todoTabName(fallbackToAll ? '0' : mainTabid)}流程`,
2010
+ total: mainListTotal,
2011
+ normalized: lists.main_list,
2012
+ });
2013
+ // 跨组去重:待处理组完整展示(不剔除),超时/紧急组剔除已在更高优先级分组出现过的流程
2014
+ const seenRequestIds = new Set();
2015
+ for (const group of groups) {
2016
+ if (group.kind === 'pending')
2017
+ continue;
2018
+ filterGroupToUnseen(group.normalized, seenRequestIds);
2019
+ }
2020
+ const combined = combineLegacyTodoGroups(groups);
2021
+ const todoSummary = {
2022
+ personalTotal: totals.personal_total,
2023
+ sharedTotal: totals.wait_read_total,
2024
+ overdueTotal,
2025
+ urgentTotal,
2026
+ forwardTotal: totals.forward_total,
2027
+ ccTotal: totals.cc_total,
2028
+ circulateTotal: totals.circulate_total,
2029
+ };
2030
+ const index = await saveSearchIndex({ groups: combined.indexGroups, items: combined.items, summary: { type: 'workflowSearch', category: 'todo', total: allTotal } }, env.ctx);
2031
+ const renderMarkdown = combined.renderMarkdown;
2032
+ // 回退时顶部行先说清口径切换(本次展示的是全部待办,而非默认的待处理),再给总量与构成
2033
+ const summaryLine = fallbackToAll
2034
+ ? `暂无需您亲自处理的流程,以下展示全部待办。${buildGroupedTodoSummaryMarkdown(allTotal, todoSummary)}`
2035
+ : buildGroupedTodoSummaryMarkdown(allTotal, todoSummary);
2036
+ const finalMarkdown = [summaryLine, renderMarkdown]
2037
+ .filter(Boolean)
2038
+ .join('\n\n');
2039
+ return workflowSuccess('workflow.search', await enforceSearchStdoutBudget({
2040
+ type: 'workflowSearch',
2041
+ renderMode: 'legacy',
2042
+ category: 'todo',
2043
+ categoryName: CATEGORY_NAMES.todo,
2044
+ displayMode: 'groupedCards',
2045
+ total: allTotal,
2046
+ hasMore: (asInt(lists.main_list.pageSize) ?? 0) * (asInt(lists.main_list.current) ?? 1) < mainListTotal,
2047
+ current: asInt(lists.main_list.current) ?? 1,
2048
+ pageSize: asInt(lists.main_list.pageSize) ?? mainSize,
2049
+ // 与普通查询返回同构:条目只输出机器可读的定位/标识/可操作性字段
2050
+ cards: combined.items.map(slimIndexItem),
2051
+ finalMarkdown,
2052
+ todoSummary,
2053
+ todoGroups: combined.groupPages,
2054
+ queryId: index.queryId,
2055
+ sessionMap: { queryId: index.queryId, groupCount: groups.length, itemCount: combined.items.length },
2056
+ }, env, { json: 'last_search.json', md: 'last_search.md' }));
2057
+ }
2058
+ /** 超时待办探查:复制主查询、tabid=6 走总数接口(来源:Python todo_overdue_probe) */
2059
+ async function probeTodoOverdue(env, query, totalKey) {
2060
+ const tabid = asString(query?.params?.tabid) ?? '';
2061
+ if (tabid === '6')
2062
+ return { skipped: true, reason: 'main_query_is_overdue', total: null };
2063
+ if (tabid === '9')
2064
+ return { skipped: true, reason: 'main_query_is_cc', total: null };
2065
+ const probeQuery = JSON.parse(JSON.stringify(query));
2066
+ probeQuery.params = { ...(probeQuery.params || {}), tabid: '6' };
2067
+ delete probeQuery.customDisplayPage;
2068
+ try {
2069
+ const response = await env.host.workflowRequest({
2070
+ method: 'POST',
2071
+ path: WORKFLOW_ENDPOINTS[totalKey],
2072
+ operation: 'workflow.search.probe.overdue',
2073
+ body: probeQuery,
2074
+ });
2075
+ return { skipped: false, query: probeQuery, total: extractTotal(response) };
2076
+ }
2077
+ catch (error) {
2078
+ rethrowAuthOrNetworkError(error);
2079
+ return { skipped: false, error: true, errorMessage: errorText(error), total: null };
2080
+ }
2081
+ }
2082
+ /** 最高紧急程度待办探查:复制主查询并追加 requestlevel 过滤(来源:Python todo_urgent_probe) */
2083
+ async function probeTodoUrgent(env, query, totalKey) {
2084
+ const existing = query?.searchParams?.formDatas?.requestlevel;
2085
+ if (Array.isArray(existing) && existing.length) {
2086
+ return { skipped: true, reason: 'main_query_has_requestlevel', total: null };
2087
+ }
2088
+ // 依赖接口失败必须算探查失败(来源:Python highest_request_level_probe_condition_nonfatal 返回
2089
+ // {"error": response} 后被 require_probe_success 拦下)——不能静默当成「无紧急待办」
2090
+ let level;
2091
+ try {
2092
+ level = await highestRequestLevelProbe(env);
2093
+ }
2094
+ catch (error) {
2095
+ rethrowAuthOrNetworkError(error);
2096
+ return { skipped: false, error: true, errorMessage: errorText(error), total: null };
2097
+ }
2098
+ if (!level)
2099
+ return { skipped: true, reason: 'no_request_level_available', total: null };
2100
+ const probeQuery = JSON.parse(JSON.stringify(query));
2101
+ probeQuery.searchParams = probeQuery.searchParams || {};
2102
+ probeQuery.searchParams.formDatas = probeQuery.searchParams.formDatas || {};
2103
+ probeQuery.searchParams.formDatas.requestlevel = [{ content: level.id }];
2104
+ delete probeQuery.customDisplayPage;
2105
+ try {
2106
+ const response = await env.host.workflowRequest({
2107
+ method: 'POST',
2108
+ path: WORKFLOW_ENDPOINTS[totalKey],
2109
+ operation: 'workflow.search.probe.urgent',
2110
+ body: probeQuery,
2111
+ });
2112
+ return { skipped: false, level, query: probeQuery, total: extractTotal(response) };
2113
+ }
2114
+ catch (error) {
2115
+ rethrowAuthOrNetworkError(error);
2116
+ return { skipped: false, level, error: true, errorMessage: errorText(error), total: null };
2117
+ }
2118
+ }
2119
+ /** 异常 → 简短可读文本(供硬失败错误详情使用)。 */
2120
+ function errorText(error) {
2121
+ if (!error)
2122
+ return '';
2123
+ if (error instanceof Error)
2124
+ return error.message;
2125
+ return String(error);
2126
+ }
2127
+ /**
2128
+ * 从 query-dependency 的 requestLevelList 选最高优先级等级
2129
+ * (来源:Python highest_request_level_probe_condition_nonfatal)。
2130
+ *
2131
+ * 依赖接口请求失败时**向上抛**——由调用方按探查失败处理(Python 侧同等情形返回 `{"error": ...}`
2132
+ * 并被 `require_probe_success` 拦下),不得静默降级为「无紧急待办」。
2133
+ */
2134
+ async function highestRequestLevelProbe(env) {
2135
+ const levels = await fetchRequestLevels(env);
2136
+ for (const word of ['紧急', '重要', '正常']) {
2137
+ const matched = levels.find((item) => item.name.includes(word));
2138
+ if (matched)
2139
+ return { id: matched.id, levelName: matched.name };
2140
+ }
2141
+ return null;
2142
+ }
2143
+ function extractTotal(response) {
2144
+ const record = responseData(response);
2145
+ const candidates = [record?.total, record?.count, record?.totalCount, record?.data?.total, record?.data?.totalCount];
2146
+ for (const c of candidates) {
2147
+ const n = asInt(c);
2148
+ if (n !== undefined)
2149
+ return n;
2150
+ }
2151
+ return null;
2152
+ }
2153
+ /** 单张卡片的三行 Markdown:标题行(含徽标)+ 元信息行(来源:Python render_card_markdown)。
2154
+ *
2155
+ * smart(智能分组)模式不渲染摘要行——与 Python `include_summary=False` 一致。
2156
+ */
2157
+ function renderCardMarkdown(card) {
2158
+ const badgeTexts = (card.badgeObjects || [])
2159
+ .map((badge) => stripHtml(badge?.text))
2160
+ .filter(Boolean)
2161
+ .map((text) => `\`${text}\``);
2162
+ const badges = badgeTexts.length ? ` ${badgeTexts.join(' ')}` : '';
2163
+ const metaParts = (card.meta || [])
2164
+ .map((pair) => {
2165
+ const label = stripHtml(pair?.label);
2166
+ const value = stripHtml(pair?.value);
2167
+ return label && value ? `${label}:${value}` : '';
2168
+ })
2169
+ .filter(Boolean);
2170
+ const meta = metaParts.join('  ');
2171
+ const titleText = `${card.index}. ${asString(card.title) ?? ''}`.trim();
2172
+ const url = asString(card.url) ?? '';
2173
+ const title = url ? `[${stripHtml(titleText)}](${url})` : stripHtml(titleText);
2174
+ const lines = [`**${title}**${badges} `];
2175
+ if (meta)
2176
+ lines.push(meta);
2177
+ return lines.join('\n');
2178
+ }
2179
+ function renderListMarkdown(normalized) {
2180
+ const items = normalized.cardItems || [];
2181
+ if (!items.length)
2182
+ return '无匹配流程';
2183
+ return items.map((card) => renderCardMarkdown(card)).join('\n\n---\n\n');
2184
+ }
2185
+ // plan 取数分页口径(来源:Python _PLAN_GROUP_FETCH_PAGE_SIZE / _PLAN_GROUP_FETCH_MAX_PAGES
2186
+ // 与 _plan_execute_slot 的 pageSize=100 / max_pages=500)
2187
+ const PLAN_FETCH_PAGE_SIZE = 100;
2188
+ const PLAN_SLOT_FETCH_MAX_PAGES = 500;
2189
+ const PLAN_GROUP_FETCH_MAX_PAGES = 50;
2190
+ /** 列表响应原始记录(与 normalizeListResponse 的取值路径一致)。 */
2191
+ function planListRecords(response) {
2192
+ const record = responseData(response);
2193
+ return toArray(record?.data?.displayData ?? record?.displayData ?? record?.data?.list ?? record?.list ?? []);
2194
+ }
2195
+ /** 覆写分页窗口(来源:Python prepare_list_query 的 current/pageSize → customDisplayPage)。 */
2196
+ function planPageQuery(baseQuery, current, size) {
2197
+ const clone = JSON.parse(JSON.stringify(baseQuery));
2198
+ clone.customDisplayPage = { current: Math.max(1, current), size: Math.max(1, Math.min(size, 100)) };
2199
+ return clone;
2200
+ }
2201
+ /** 槽位/组总数以独立总数接口为准(来源:Python prepare_total_query + extract_total_count)。
2202
+ *
2203
+ * 列表接口返回的 total 与展示窗口不同口径,不能直接当组内条数用;失败返回 null 由调用方降级。
2204
+ */
2205
+ async function planTotalCount(env, query, category) {
2206
+ const prefix = ENDPOINT_PREFIX[category];
2207
+ const totalKey = `${prefix}_TOTAL`;
2208
+ const body = JSON.parse(JSON.stringify(query));
2209
+ delete body.customDisplayPage;
2210
+ try {
2211
+ const response = await env.host.workflowRequest({
2212
+ method: 'POST',
2213
+ path: WORKFLOW_ENDPOINTS[totalKey],
2214
+ operation: 'workflow.search.plan.total',
2215
+ body,
2216
+ });
2217
+ return extractTotal(response);
2218
+ }
2219
+ catch (error) {
2220
+ // 认证/网络错误不得降级成 null(否则各组总数会静默变 0,用户看到的是"没有待办"而不是"没登录")
2221
+ rethrowAuthOrNetworkError(error);
2222
+ return null;
2223
+ }
2224
+ }
2225
+ /** 翻页取数并按「合并记录后一次 normalize」返回(来源:Python _plan_execute_slot 的 requestId 收集
2226
+ * 循环、_plan_execute_regular_group 的翻页合并 —— 合并后统一编号,保证卡片序号全局连续)。
2227
+ *
2228
+ * pageThrough=false 时仅取第一页展示窗口(用于展开组「无草稿」快速通道)。
2229
+ */
2230
+ async function planCollectRecords(env, baseQuery, category, totalCount, options) {
2231
+ const merged = [];
2232
+ const seen = new Set();
2233
+ let firstPageTotal = null;
2234
+ let current = 1;
2235
+ let size = options.firstPageSize;
2236
+ for (let page = 0; page < options.maxPages; page += 1) {
2237
+ const response = await env.host.workflowRequest({
2238
+ method: 'POST',
2239
+ path: WORKFLOW_ENDPOINTS[options.listKey],
2240
+ operation: options.operation,
2241
+ body: planPageQuery(baseQuery, current, size),
2242
+ });
2243
+ if (firstPageTotal === null)
2244
+ firstPageTotal = extractTotal(response);
2245
+ const rows = planListRecords(response);
2246
+ if (!rows.length)
2247
+ break;
2248
+ let added = 0;
2249
+ for (const row of rows) {
2250
+ const rid = asString(row?.requestId ?? row?.requestid ?? row?.id) ?? '';
2251
+ if (rid && seen.has(rid))
2252
+ continue;
2253
+ if (rid)
2254
+ seen.add(rid);
2255
+ merged.push(row);
2256
+ added += 1;
2257
+ }
2258
+ if (!options.pageThrough)
2259
+ break;
2260
+ const bound = totalCount;
2261
+ if (bound !== null && merged.length >= bound)
2262
+ break;
2263
+ // 本页无新增或不满一页 → 已到末页(来源:Python _plan_execute_regular_group 的 added==0 判定)
2264
+ if (added === 0 || rows.length < size)
2265
+ break;
2266
+ current += 1;
2267
+ size = PLAN_FETCH_PAGE_SIZE;
2268
+ }
2269
+ const effectiveTotal = totalCount ?? firstPageTotal ?? merged.length;
2270
+ const mergedResponse = {
2271
+ data: {
2272
+ data: {
2273
+ displayData: merged,
2274
+ total: effectiveTotal,
2275
+ current: 1,
2276
+ pageSize: merged.length || 1,
2277
+ hasMore: false,
2278
+ },
2279
+ },
2280
+ };
2281
+ return {
2282
+ records: merged,
2283
+ normalized: normalizeListResponse(mergedResponse, category, CATEGORY_NAMES[category], options.baseUrl),
2284
+ };
2285
+ }
2286
+ /** smart 纯卡片渲染:展示窗口截断到 pageCap(来源:Python _plan_render_first_page)。 */
2287
+ function renderPlanCards(normalized, pageCap) {
2288
+ const cards = normalized.cardItems || [];
2289
+ return renderListMarkdown({ ...normalized, cardItems: cards.slice(0, Math.max(0, pageCap)) });
2290
+ }
2291
+ // ---------------------------------------------------------------------------
2292
+ // workflow.search --plan-json 批量编排(来源:cmd_workflow_search_plan / _plan_execute_regular_group)
2293
+ // ---------------------------------------------------------------------------
2294
+ /**
2295
+ * plan 展开组的**错误兜底**:条件解析失败只判该组 `error`,不让整份 plan 失败。
2296
+ *
2297
+ * 对齐 V11.3:`_plan_execute_regular_group` 对 `condition` 解析失败一律
2298
+ * `return {"groupName": group_name, "status": "error", "error": err}`(L13316-13322),
2299
+ * 文档侧口径见 `workflow-search.md:225`「0 命中或多命中判该组 `error`(plan 内不做澄清)」。
2300
+ *
2301
+ * 只吞 `validation` 类错误——认证/网络错误必须原样抛出(否则登录过期会被伪装成"这组条件有问题")。
2302
+ * 非 `WorkflowCliError` 一律重抛:那是程序缺陷,不该被降级成数据结论。
2303
+ */
2304
+ function planGroupError(groupName, error) {
2305
+ if (!(error instanceof WorkflowCliError) || error.type !== 'validation')
2306
+ throw error;
2307
+ const details = (error.details ?? {});
2308
+ const clarification = error.subtype === CLARIFICATION_SUBTYPE;
2309
+ return {
2310
+ groupName,
2311
+ status: 'error',
2312
+ collapsed: false,
2313
+ // 与 V11.3 的组内文案同口径(`_plan_system_field_form_datas`:
2314
+ // 「plan 内不做澄清,请改用 名称(ID) 形式」)——否则模型会把澄清问题当成"该组没有数据"。
2315
+ error: clarification
2316
+ ? `${error.message}(plan 内不做澄清:请把候选原样交给用户确认,再用「名称(ID)」重跑该组)`
2317
+ : error.message,
2318
+ ...(clarification
2319
+ ? {
2320
+ clarificationType: details.clarificationType ?? null,
2321
+ clarificationKeyword: details.clarificationKeyword ?? null,
2322
+ candidates: Array.isArray(details.candidates) ? details.candidates : [],
2323
+ }
2324
+ : {}),
2325
+ };
2326
+ }
2327
+ /**
2328
+ * 执行一个**展开组**(plan 阶段二)。组内所有错误由调用方 `planGroupError` 兜底成 `status:'error'`。
2329
+ *
2330
+ * 返回 `{groupName,status,total,hasMore,finalMarkdown,pureDraft,draftCount,nonDraftCount}`。
2331
+ */
2332
+ async function executeRegularPlanGroup(env, group, groupName, category, pageCap, countDetails, excludeIds, baseUrl) {
2333
+ // 纯草稿组:stat 计数已证明全组草稿 —— 零明细请求,直接返回折叠元数据
2334
+ // (来源:Python _plan_draft_precheck + _plan_execute_regular_group 的 pure 分支)
2335
+ const precheck = planDraftPrecheck(group, countDetails);
2336
+ if (precheck.verdict === 'pure') {
2337
+ return {
2338
+ groupName,
2339
+ status: 'ok',
2340
+ collapsed: false,
2341
+ total: precheck.totalSum,
2342
+ hasMore: false,
2343
+ finalMarkdown: '',
2344
+ pureDraft: true,
2345
+ draftCount: precheck.draftSum,
2346
+ nonDraftCount: 0,
2347
+ };
2348
+ }
2349
+ const condition = normalizePlanCondition(group.condition);
2350
+ const subArgs = { ...condition, category };
2351
+ // 排除集 = 特殊组自动收集 ∪ 本组 condition.exRequestIds(来源:Python _plan_execute_regular_group
2352
+ // 的 `list(exclude_ids) + scope_ex + group_ex`)
2353
+ const mergedExRequestIds = [...new Set([...excludeIds, ...splitRequestIds(condition.exRequestId)])];
2354
+ if (mergedExRequestIds.length)
2355
+ subArgs.exRequestId = mergedExRequestIds.join(',');
2356
+ else
2357
+ delete subArgs.exRequestId;
2358
+ const { query } = await buildSearchQuery(env, subArgs, 'list');
2359
+ // 组总数走独立总数接口(列表接口 total 口径不等于组内条数,不可作为翻页上界);明细按该总数封顶。
2360
+ // 无草稿组只取展示窗口;混合/未知组翻页取全,以便逐条判定草稿并精确剔除
2361
+ // (来源:Python _plan_execute_regular_group)。
2362
+ const groupTotal = await planTotalCount(env, query, category);
2363
+ const noDraftFastChannel = precheck.verdict === 'none';
2364
+ const fetchOptions = {
2365
+ firstPageSize: noDraftFastChannel ? pageCap : PLAN_FETCH_PAGE_SIZE,
2366
+ pageThrough: !noDraftFastChannel,
2367
+ maxPages: PLAN_GROUP_FETCH_MAX_PAGES,
2368
+ operation: 'workflow.search.plan.group',
2369
+ listKey: 'TODO_LIST',
2370
+ baseUrl,
2371
+ };
2372
+ let collected = await planCollectRecords(env, query, category, groupTotal, fetchOptions);
2373
+ // 快速通道页内粗检草稿:stat 过期(宣称无草稿但明细有)→ 降级为大页翻页取全
2374
+ // (来源:Python _plan_execute_regular_group 的 mixed 降级分支)
2375
+ let degradationUsed = false;
2376
+ if (noDraftFastChannel && planHasDraftItem(collected.normalized)) {
2377
+ collected = await planCollectRecords(env, query, category, groupTotal, {
2378
+ ...fetchOptions,
2379
+ firstPageSize: PLAN_FETCH_PAGE_SIZE,
2380
+ pageThrough: true,
2381
+ });
2382
+ degradationUsed = true;
2383
+ }
2384
+ const effectiveGroupTotal = asInt(collected.normalized.total) ?? groupTotal ?? 0;
2385
+ const normalized = collected.normalized;
2386
+ // 草稿卡片不逐条展示:从渲染列表剔除,数量单独上报(来源:Python 展开组 draft_flags 处理)
2387
+ const cardItems = normalized.cardItems || [];
2388
+ const itemList = normalized.items || [];
2389
+ const draftFlags = cardItems.map((_card, position) => PLAN_GROUP_DRAFT_LABELS.has(asString(itemList[position]?.flowStatus) ?? ''));
2390
+ const draftCount = draftFlags.filter(Boolean).length;
2391
+ const nonDraftCount = cardItems.length - draftCount;
2392
+ const pureDraft = cardItems.length > 0 && nonDraftCount === 0;
2393
+ let hasMore;
2394
+ if (pureDraft) {
2395
+ hasMore = false;
2396
+ }
2397
+ else if (noDraftFastChannel && !degradationUsed) {
2398
+ // 快速通道只取了展示窗口:未展示的非草稿 = 组总数 - 页内已渲染条数
2399
+ hasMore = effectiveGroupTotal > nonDraftCount;
2400
+ }
2401
+ else {
2402
+ hasMore = nonDraftCount > pageCap;
2403
+ }
2404
+ const rendered = {
2405
+ ...normalized,
2406
+ items: normalized.items.filter((_item, position) => !draftFlags[position]),
2407
+ cardItems: cardItems.filter((_card, position) => !draftFlags[position]),
2408
+ };
2409
+ return {
2410
+ groupName,
2411
+ status: 'ok',
2412
+ collapsed: false,
2413
+ total: effectiveGroupTotal,
2414
+ hasMore,
2415
+ finalMarkdown: pureDraft ? '' : renderPlanCards(rendered, pageCap),
2416
+ pureDraft,
2417
+ draftCount,
2418
+ nonDraftCount,
2419
+ };
2420
+ }
2421
+ async function executePlanSearch(env, plan, planPageSize = 10) {
2422
+ const category = (asString(plan.category) || 'todo');
2423
+ if (category !== 'todo') {
2424
+ throw workflowValidationError('plan_category', 'plan.category 目前仅支持 todo');
2425
+ }
2426
+ const pageCap = Math.max(1, Math.min(asInt(planPageSize) ?? 10, 100));
2427
+ const groups = plan.groups;
2428
+ if (!Array.isArray(groups) || !groups.length) {
2429
+ throw workflowValidationError('plan_groups', 'plan.groups 必须是非空分组数组');
2430
+ }
2431
+ if (groups.length > PLAN_MAX_GROUPS) {
2432
+ throw workflowValidationError('plan_groups', `plan.groups 展开+折叠组总数不能超过 ${PLAN_MAX_GROUPS}`, { groupCount: groups.length });
2433
+ }
2434
+ // 特殊组类型必须合法(来源:Python cmd_workflow_search_plan 的 unknown_special 校验)——
2435
+ // 静默丢弃会让"模型声明了却没生效"无从排查。
2436
+ const rawSpecialGroups = plan.specialGroups;
2437
+ if (rawSpecialGroups !== undefined && rawSpecialGroups !== null && !Array.isArray(rawSpecialGroups)) {
2438
+ throw workflowValidationError('plan_special_groups', 'plan.specialGroups 必须是数组(如 ["urgentOvertime", "forwardToSelf"])');
2439
+ }
2440
+ const specialGroups = Array.isArray(rawSpecialGroups) ? rawSpecialGroups : [];
2441
+ const unknownSpecial = specialGroups.filter((sg) => !PLAN_SPECIAL_GROUP_TYPES.includes(asString(sg) ?? ''));
2442
+ if (unknownSpecial.length) {
2443
+ throw workflowValidationError('plan_special_groups', `plan.specialGroups 含不支持的类型 ${JSON.stringify(unknownSpecial)}(支持:${JSON.stringify(PLAN_SPECIAL_GROUP_TYPES)})`, { supported: PLAN_SPECIAL_GROUP_TYPES });
2444
+ }
2445
+ // 每个分组必须带非空 groupName(来源:Python 同名校验)——否则分组标题无从渲染
2446
+ groups.forEach((group, i) => {
2447
+ if (!group || typeof group !== 'object' || !(asString(group.groupName) ?? '').trim()) {
2448
+ throw workflowValidationError('plan_group_name', `plan.groups 第 ${i + 1} 项必须是含非空 groupName 的对象`, { index: i + 1 });
2449
+ }
2450
+ });
2451
+ const session = await env.host.getSession();
2452
+ // 取最近待办统计快照(countDetails):折叠组据其代码统计、特殊组据其有货探测。
2453
+ // 与 Python _plan_load_count_details 等价(Python 读 last_stat.json;此处对 TodoStat 接口取全量快照)。
2454
+ const countDetails = await fetchPlanStatSnapshot(env, plan);
2455
+ const specialOutputs = [];
2456
+ const groupOutputs = [];
2457
+ // 阶段一:特殊组(有货才跑;urgent 槽带 requestlevel=2)
2458
+ const excludeIds = [];
2459
+ for (const sg of specialGroups) {
2460
+ if (sg === 'urgentOvertime' || sg === 'forwardToSelf') {
2461
+ if (countDetails.length && !planSpecialHasData(sg, countDetails))
2462
+ continue;
2463
+ const slots = [];
2464
+ const slotTypes = sg === 'urgentOvertime' ? ['overtime', 'urgent'] : ['forward'];
2465
+ for (const slotType of slotTypes) {
2466
+ const tabid = PLAN_SLOT_TABID[slotType];
2467
+ const { query } = await buildSearchQuery(env, { ...normalizePlanCondition(plan.condition), tabid, category }, 'list');
2468
+ if (slotType === 'urgent') {
2469
+ query.searchParams = query.searchParams || {};
2470
+ query.searchParams.formDatas = query.searchParams.formDatas || {};
2471
+ query.searchParams.formDatas.requestlevel = [{ content: '2' }];
2472
+ }
2473
+ // 槽位总数走独立总数接口(列表 total 口径不可靠);同槽翻页取全 requestId 作为排除集。
2474
+ const slotTotal = await planTotalCount(env, query, category);
2475
+ const collected = await planCollectRecords(env, query, category, slotTotal, {
2476
+ firstPageSize: PLAN_FETCH_PAGE_SIZE,
2477
+ pageThrough: true,
2478
+ maxPages: PLAN_SLOT_FETCH_MAX_PAGES,
2479
+ operation: 'workflow.search.plan.special',
2480
+ listKey: 'TODO_LIST',
2481
+ baseUrl: session.baseUrl ?? '',
2482
+ });
2483
+ for (const record of collected.records) {
2484
+ const rid = asString(record?.requestId ?? record?.requestid ?? record?.id);
2485
+ if (rid && !excludeIds.includes(rid))
2486
+ excludeIds.push(rid);
2487
+ }
2488
+ slots.push({
2489
+ slotType,
2490
+ total: slotTotal ?? collected.records.length,
2491
+ hasMore: slotTotal !== null && slotTotal > pageCap,
2492
+ finalMarkdown: renderPlanCards(collected.normalized, pageCap),
2493
+ });
2494
+ }
2495
+ if (slots.length) {
2496
+ specialOutputs.push({
2497
+ groupType: sg,
2498
+ displayName: sg === 'urgentOvertime' ? '紧急/超时' : '转发给您',
2499
+ slots,
2500
+ excludeCount: excludeIds.length,
2501
+ });
2502
+ }
2503
+ }
2504
+ }
2505
+ // 阶段二:常规组(折叠组按 stat 快照代码统计、不调列表接口;展开组取明细并注入特殊组排除集)。
2506
+ //
2507
+ // **逐组隔离**:某一组的条件解析不出来(名称 0 命中/多命中、字段名未识别…)只把该组标成
2508
+ // `status:'error'` 并把原因与候选带回,不能让整份 plan 失败——这是 V11.3 的既定口径
2509
+ // (`_plan_execute_regular_group` → `{"groupName":..., "status":"error", "error":err}`),
2510
+ // 也是 plan 的意义所在:组是彼此独立的子查询,坏一组不能毁掉其余组的结果。
2511
+ for (const group of groups) {
2512
+ const collapsed = asBool(group.collapsed, false);
2513
+ if (collapsed) {
2514
+ groupOutputs.push(planCollapseGroup(group, countDetails));
2515
+ continue;
2516
+ }
2517
+ const groupName = asString(group.groupName) || '未命名分组';
2518
+ try {
2519
+ groupOutputs.push(await executeRegularPlanGroup(env, group, groupName, category, pageCap, countDetails, excludeIds, session.baseUrl ?? ''));
2520
+ }
2521
+ catch (error) {
2522
+ groupOutputs.push(planGroupError(groupName, error));
2523
+ }
2524
+ }
2525
+ return workflowSuccess('workflow.search', await enforceSearchStdoutBudget({
2526
+ type: 'workflowSearchPlan',
2527
+ category,
2528
+ specialGroups: specialOutputs,
2529
+ groups: groupOutputs,
2530
+ excludeRequestIdCount: excludeIds.length,
2531
+ statCount: countDetails.length,
2532
+ displayRules: [
2533
+ 'plan 返回为批量编排结果:特殊组/展开组各段 finalMarkdown 均为纯卡片列表(无顶部行),原样粘贴到对应分组标题与一行汇总摘要下',
2534
+ '紧急/超时组标题计数:N=该组各槽 total 之和、M=urgent 槽 total、K=overtime 槽 total(0 项省略),如 ## 紧急/超时(2 条|紧急 1 条|超时 1 条)',
2535
+ '折叠组(collapsed=true)只返回 total 与 byWorkflow 数量,不展开明细;status=error 的组未取数,按 error 提示修正后可单独作为单条 workflow.search 查询',
2536
+ 'status=error 的组:原样把 error 交给用户。若该组同时带 clarificationType/candidates,说明是**名称没对上**(不是"该组没有数据")——请把候选按「名称(ID)」摆给用户确认,再用该组条件单独发一次 workflow.search,禁止当作 0 条渲染或自行挑一个候选',
2537
+ '任一流程只出现在一个分组:特殊组流程已注入 exRequestIds 从展开组排除',
2538
+ '展开组 pureDraft=true:该组卡片全部为本人待提交草稿(draftCount=total),不要渲染其 finalMarkdown(为空),全部并入折叠组「待提交草稿(N 条)」(N=各 pureDraft 组 draftCount 之和)',
2539
+ '展开组 pureDraft=false:其 finalMarkdown 已只渲染非草稿卡片(草稿卡片被剔除、数量见 draftCount),直接整段原样粘贴;组内草稿数量在标题/汇总注明即可,无需再逐条展示草稿',
2540
+ '分组标题格式:## 组名(N 条);分组顺序:紧急/超时 → 转发给您 → 需要您重点关注的流程 → 业务域展开组 → 待提交草稿 → 知会类折叠组',
2541
+ '分组卡片渲染中禁止把卡片输出成 Markdown 表格、或压成连续文本编号列表(**仅约束卡片渲染**,不影响发起表单预览的明细表与批量操作结果表);卡片必须原样保留标题链接、状态徽标与「标签:值」元信息行',
2542
+ ],
2543
+ }, env, { json: 'last_plan.json', md: 'last_plan.md' }));
2544
+ }
2545
+ /** requestId 条件值 → 逗号分隔文本(数组或已分隔字符串都接受)。 */
2546
+ function requestIdListText(value) {
2547
+ if (Array.isArray(value))
2548
+ return value.map((v) => String(v ?? '').trim()).filter(Boolean).join(',');
2549
+ if (value === undefined || value === null)
2550
+ return '';
2551
+ return String(value).trim();
2552
+ }
2553
+ /** plan condition 键归一(来源:Python _plan_request_id_list / _plan_execute_regular_group)。
2554
+ *
2555
+ * 契约键是复数 `requestIds` / `exRequestIds`(V11.3 plan 规范),而 buildSearchQuery 读的是
2556
+ * 单数 `requestId` / `exRequestId`。若不做归一,模型按契约传复数键会被**静默丢弃**——
2557
+ * 实测 condition.requestIds=["<某条>"] 直接退化成全量待办(total 926 而非 1),
2558
+ * 会让「需要您重点关注的流程」组失效。此处复数/单数两种写法都接受。
2559
+ */
2560
+ function normalizePlanCondition(condition) {
2561
+ const source = condition && typeof condition === 'object' ? { ...condition } : {};
2562
+ const requestIds = source.requestIds !== undefined ? source.requestIds : source.requestId;
2563
+ if (requestIds !== undefined) {
2564
+ const text = requestIdListText(requestIds);
2565
+ if (text)
2566
+ source.requestId = text;
2567
+ else
2568
+ delete source.requestId;
2569
+ }
2570
+ delete source.requestIds;
2571
+ const exRequestIds = source.exRequestIds !== undefined ? source.exRequestIds : source.exRequestId;
2572
+ if (exRequestIds !== undefined) {
2573
+ const text = requestIdListText(exRequestIds);
2574
+ if (text)
2575
+ source.exRequestId = text;
2576
+ else
2577
+ delete source.exRequestId;
2578
+ }
2579
+ delete source.exRequestIds;
2580
+ return source;
2581
+ }
2582
+ /** 逗号分隔 requestId 文本 → 去重数组。 */
2583
+ function splitRequestIds(text) {
2584
+ return requestIdListText(text)
2585
+ .split(',')
2586
+ .map((item) => item.trim())
2587
+ .filter(Boolean);
2588
+ }
2589
+ /** 快速通道页内草稿粗检(来源:Python _plan_execute_regular_group 的 none 分支页内检查,
2590
+ * 明细层判定口径为 flowStatus ∈ {草稿,待提交})。 */
2591
+ function planHasDraftItem(normalized) {
2592
+ const items = normalized.items || [];
2593
+ return items.some((item) => PLAN_GROUP_DRAFT_LABELS.has(asString(item?.flowStatus) ?? ''));
2594
+ }
2595
+ /** 展开组草稿构成预判(来源:Python _plan_draft_precheck;仅依据 stat 计数,不发明细查询)。
2596
+ *
2597
+ * pure = draftCount 合计 > 0 且 == totalCount 合计(纯草稿);
2598
+ * none = draftCount 合计 == 0(无草稿);mixed = 介于两者之间;unknown = 组无 workflow 成员或未命中 stat 行。
2599
+ */
2600
+ function planDraftPrecheck(group, countDetails) {
2601
+ const condition = group?.condition && typeof group.condition === 'object' ? group.condition : {};
2602
+ const members = condition.workflow;
2603
+ const memberList = Array.isArray(members) ? members : members ? [members] : [];
2604
+ if (!memberList.length)
2605
+ return { verdict: 'unknown', totalSum: 0, draftSum: 0 };
2606
+ const workflowIds = new Set();
2607
+ for (const member of memberList) {
2608
+ const parsed = parseNameWithId(asString(member) ?? '');
2609
+ if (!parsed.id)
2610
+ return { verdict: 'unknown', totalSum: 0, draftSum: 0 };
2611
+ workflowIds.add(parsed.id);
2612
+ }
2613
+ const rows = (countDetails || []).filter((row) => row && typeof row === 'object' && workflowIds.has(asString(row.workflowId) ?? ''));
2614
+ if (!rows.length)
2615
+ return { verdict: 'unknown', totalSum: 0, draftSum: 0 };
2616
+ let totalSum = 0;
2617
+ let draftSum = 0;
2618
+ for (const row of rows) {
2619
+ totalSum += asInt(row.totalCount) ?? 0;
2620
+ draftSum += asInt(row.draftCount) ?? 0;
2621
+ }
2622
+ if (draftSum > 0 && draftSum === totalSum)
2623
+ return { verdict: 'pure', totalSum, draftSum };
2624
+ if (draftSum === 0)
2625
+ return { verdict: 'none', totalSum, draftSum };
2626
+ return { verdict: 'mixed', totalSum, draftSum };
2627
+ }
2628
+ // plan 折叠组/特殊组相关的参与身份集合(来源:Python _PLAN_FOLD_REMARK_NAMES / _PLAN_FORWARD_REMARK_NAMES,L12859)
2629
+ const PLAN_FOLD_REMARK_NAMES = new Set([
2630
+ '传阅接收人', '传阅接收人(委托)', '传阅接收人不需反馈', '传阅接收人不需反馈(委托)',
2631
+ '抄送接收人', '抄送接收人(委托)', '抄送接收人不需反馈', '抄送接收人不需反馈(委托)',
2632
+ ]);
2633
+ const PLAN_FORWARD_REMARK_NAMES = new Set([
2634
+ '转发接收人', '转发接收人(委托)', '转发接收人不需反馈', '转发接收人不需反馈(委托)',
2635
+ ]);
2636
+ async function fetchPlanStatSnapshot(env, plan) {
2637
+ // 优先复用最近一次 workflow.todoStat 的落盘快照(来源:Python _plan_load_count_details)——
2638
+ // 与模型规划分组用的是同一份口径,且省一次统计请求;快照缺失/过期再回退到实时查询。
2639
+ try {
2640
+ const text = await readStateFile('last_stat.json', env.ctx);
2641
+ if (text) {
2642
+ const parsed = JSON.parse(text);
2643
+ const rows = toArray(parsed?.countDetails ?? parsed?.data?.countDetails ?? []);
2644
+ if (rows.length)
2645
+ return rows;
2646
+ }
2647
+ }
2648
+ catch {
2649
+ // 快照不可用 → 走实时查询
2650
+ }
2651
+ try {
2652
+ const { query } = await buildSearchQuery(env, { ...normalizePlanCondition(plan.condition), category: 'todo' }, 'stat');
2653
+ const response = await env.host.workflowRequest({
2654
+ method: 'POST',
2655
+ path: WORKFLOW_ENDPOINTS.TODO_GROUP_STAT,
2656
+ operation: 'workflow.todoStat',
2657
+ body: query,
2658
+ });
2659
+ const data = responseData(response);
2660
+ return toArray(data?.countDetails ?? []);
2661
+ }
2662
+ catch (error) {
2663
+ // 认证/网络错误不得被当成"该组无数据"——否则登录过期会被伪装成「特殊组为空」静默少渲染
2664
+ rethrowAuthOrNetworkError(error);
2665
+ return [];
2666
+ }
2667
+ }
2668
+ function planSpecialHasData(groupType, countDetails) {
2669
+ if (groupType === 'urgentOvertime') {
2670
+ return countDetails.some((row) => (asInt(row?.urgentCount) ?? 0) > 0) ||
2671
+ countDetails.some((row) => (asInt(row?.overtimeCount) ?? 0) > 0);
2672
+ }
2673
+ if (groupType === 'forwardToSelf') {
2674
+ return countDetails.some((row) => {
2675
+ const name = asString(row?.isRemarkName);
2676
+ return PLAN_FORWARD_REMARK_NAMES.has(name ?? '') && ((asInt(row?.forwardToSelfCount) ?? 0) > 0 || (asInt(row?.totalCount) ?? 0) > 0);
2677
+ });
2678
+ }
2679
+ return false;
2680
+ }
2681
+ function planCollapseGroup(group, countDetails) {
2682
+ const groupName = asString(group?.groupName) || '未命名折叠组';
2683
+ const condition = group?.condition || {};
2684
+ // 折叠组只支持 workflow(数量按统计快照口径统计,不发明细查询);其余键一律判 error。
2685
+ // 单数/复数两种写法都拒(与 Python _PLAN_COLLAPSED_CONDITION_KEYS 一致)。
2686
+ const unsupported = [
2687
+ 'receiveTime', 'createTime', 'requestLevel', 'filterFields',
2688
+ 'requestIds', 'exRequestIds', 'requestId', 'exRequestId', 'creator', 'workflowType',
2689
+ ].filter((k) => condition[k]);
2690
+ if (unsupported.length) {
2691
+ return { groupName, status: 'error', collapsed: true, error: `折叠组按统计快照数量统计,不支持 ${unsupported.join('/')}——需要该条件时请改为展开组` };
2692
+ }
2693
+ let memberIds = null;
2694
+ const wf = condition.workflow;
2695
+ if (wf) {
2696
+ const members = Array.isArray(wf) ? wf : [wf];
2697
+ memberIds = new Set();
2698
+ for (const m of members) {
2699
+ const parsed = parseNameWithId(asString(m) ?? '');
2700
+ if (parsed.id)
2701
+ memberIds.add(parsed.id);
2702
+ }
2703
+ }
2704
+ const matched = (countDetails || []).filter((row) => {
2705
+ if (!row || typeof row !== 'object')
2706
+ return false;
2707
+ if (!PLAN_FOLD_REMARK_NAMES.has(asString(row.isRemarkName) ?? ''))
2708
+ return false;
2709
+ if (memberIds && !memberIds.has(asString(row.workflowId) ?? ''))
2710
+ return false;
2711
+ return true;
2712
+ });
2713
+ const total = matched.reduce((sum, row) => sum + (asInt(row.totalCount) ?? 0), 0);
2714
+ const byWorkflow = {};
2715
+ for (const row of matched) {
2716
+ const wfName = asString(row.workflowName) || asString(row.workflowId) || '未知';
2717
+ byWorkflow[wfName] = (byWorkflow[wfName] ?? 0) + (asInt(row.totalCount) ?? 0);
2718
+ }
2719
+ const result = {
2720
+ groupName,
2721
+ status: 'ok',
2722
+ collapsed: true,
2723
+ total,
2724
+ byWorkflow: Object.keys(byWorkflow).map((name) => ({ workflowName: name, count: byWorkflow[name] })),
2725
+ };
2726
+ if (total === 0) {
2727
+ result.warning = '按折叠身份口径(传阅/抄送接收人及委托)未在 countDetails 命中数据——请核对成员工作流或考虑改为展开组';
2728
+ }
2729
+ return result;
2730
+ }
2731
+ // ---------------------------------------------------------------------------
2732
+ // workflow.todoStat(来源:cmd_workflow_todo_stat,TODO_GROUP_STAT,必带 category=todo)
2733
+ // ---------------------------------------------------------------------------
2734
+ /** todoStat 输出安全线(来源:Python WORKFLOW_SEARCH_STDOUT_SAFE_BYTES)。 */
2735
+ const TODO_STAT_STDOUT_SAFE_BYTES = 50_000;
2736
+ /** 行内 requestIds(逗号分隔 CSV)超过该长度就只留条数,明细留在 resultFile。
2737
+ *
2738
+ * 抄送/传阅类行的 requestIds 可达数百条(≈17KB),是另一处截断隐患。 */
2739
+ const TODO_STAT_ROW_ID_LIST_MAX_CHARS = 1_000;
2740
+ /** 行内 requestIds 超长时退化为条数(全量仍在 resultFile)。 */
2741
+ function todoStatTrimRowDetail(row) {
2742
+ if (!row || typeof row !== 'object')
2743
+ return row;
2744
+ const ids = row.requestIds;
2745
+ if (typeof ids !== 'string' || ids.length <= TODO_STAT_ROW_ID_LIST_MAX_CHARS)
2746
+ return row;
2747
+ const trimmed = { ...row };
2748
+ delete trimmed.requestIds;
2749
+ trimmed.requestIdsCount = ids.split(',').filter((part) => part.trim()).length;
2750
+ trimmed.requestIdsOmitted = true;
2751
+ return trimmed;
2752
+ }
2753
+ /** 精简统计行保留的字段(来源:Python compact_todo_stat_count_details)。
2754
+ *
2755
+ * 去掉行内 formFields(体积主体),保留全部数量类字段与 requestIds/requestInfos
2756
+ * ——后者是「需要您重点关注的流程」逐条判定的依据。 */
2757
+ const TODO_STAT_COMPACT_KEYS = [
2758
+ 'workflowId', 'workflowName', 'workflowTypeId', 'workflowTypeName',
2759
+ 'nodeId', 'nodeName', 'nodeTypeName', 'isRemark', 'isRemarkName',
2760
+ 'totalCount', 'forwardToSelfCount', 'urgentCount', 'overtimeCount', 'managerForwardToSelfCount',
2761
+ 'pendingCount', 'draftCount', 'beRejectCount',
2762
+ 'requestIds', 'requestInfos',
2763
+ ];
2764
+ /** requestInfos 条目保留字段:分组逐条判定只用 ID / 标题 / 发起时间 / 字段值前缀。 */
2765
+ const TODO_STAT_REQUEST_INFO_KEYS = ['requestId', 'requestNameTitle', 'createDateTime'];
2766
+ /** 条目字段值在 stdout 里保留的最大字符数(超出部分留在 resultFile)。
2767
+ *
2768
+ * 实测通知类条目的「留言内容」可达 2,800 字(占单行 22KB 的绝大部分),而逐条判定
2769
+ * 需要的受众/执行要求都写在正文开头,保留前缀即可判定;完整正文按需从 resultFile 取。 */
2770
+ const TODO_STAT_FORM_FIELD_VALUE_MAX_CHARS = 200;
2771
+ function todoStatCompactRequestInfo(item) {
2772
+ if (!item || typeof item !== 'object')
2773
+ return item;
2774
+ const kept = {};
2775
+ for (const key of TODO_STAT_REQUEST_INFO_KEYS) {
2776
+ if (key in item)
2777
+ kept[key] = item[key];
2778
+ }
2779
+ const values = item.formFieldValues;
2780
+ if (values && typeof values === 'object') {
2781
+ const trimmed = {};
2782
+ let truncated = false;
2783
+ for (const [name, value] of Object.entries(values)) {
2784
+ if (typeof value !== 'string' || value.length <= TODO_STAT_FORM_FIELD_VALUE_MAX_CHARS) {
2785
+ trimmed[name] = value;
2786
+ continue;
2787
+ }
2788
+ trimmed[name] = `${value.slice(0, TODO_STAT_FORM_FIELD_VALUE_MAX_CHARS)}…`;
2789
+ truncated = true;
2790
+ }
2791
+ kept.formFieldValues = trimmed;
2792
+ if (truncated)
2793
+ kept.formFieldValuesTruncated = true;
2794
+ }
2795
+ return kept;
2796
+ }
2797
+ function todoStatCompactRow(item) {
2798
+ if (!item || typeof item !== 'object')
2799
+ return item;
2800
+ const kept = {};
2801
+ for (const key of TODO_STAT_COMPACT_KEYS) {
2802
+ if (key in item)
2803
+ kept[key] = item[key];
2804
+ }
2805
+ if (Array.isArray(kept.requestInfos)) {
2806
+ kept.requestInfos = kept.requestInfos.map(todoStatCompactRequestInfo);
2807
+ }
2808
+ return todoStatTrimRowDetail(kept);
2809
+ }
2810
+ /**
2811
+ * 统计结果过大时落盘 + 返回精简版(来源:Python enforce_todo_stat_stdout_budget)。
2812
+ *
2813
+ * 目的:避免 180KB+ 的 countDetails 全量进 stdout,逼模型现场写脚本解析
2814
+ * (实测会多花 3~4 步且易解析错)。行**完整**返回、只去掉行内 formFields;
2815
+ * 需要对某工作流做字段分组判断时才从 resultFile 针对性取该行 formFields。
2816
+ * 落盘不可用时保持全量输出(宁大不丢数据),并给出提示。
2817
+ */
2818
+ async function compactTodoStatOutput(result, env) {
2819
+ const bytes = Buffer.byteLength(JSON.stringify(result), 'utf8');
2820
+ if (bytes <= TODO_STAT_STDOUT_SAFE_BYTES)
2821
+ return result;
2822
+ const resultFile = await writeStateFileBestEffort('last_stat.json', JSON.stringify(result, null, 2), env.ctx);
2823
+ if (!resultFile) {
2824
+ return {
2825
+ ...result,
2826
+ outputBytes: bytes,
2827
+ largeOutputNotice: '统计结果较大且本地落盘不可用,已按原样返回全量 countDetails(行内含 formFields)。',
2828
+ };
2829
+ }
2830
+ const compact = withLeadingMeta({
2831
+ type: result.type || 'workflowTodoStat',
2832
+ resultFile,
2833
+ persistedResult: true,
2834
+ outputBytes: bytes,
2835
+ largeOutputNotice: '统计结果较大且含表单/留言正文,完整数据已写入 resultFile;'
2836
+ + `上方 countDetails 各行完整(数量字段 + requestInfos 的 ID/标题/发起时间/字段值都在),`
2837
+ + `仅移出体积大的行级 formFields,并把超长条目字段值截到前 ${TODO_STAT_FORM_FIELD_VALUE_MAX_CHARS} 字`
2838
+ + '(判定所需的受众/执行要求写在正文开头,前缀已够用;带 formFieldValuesTruncated 标记的行如需全文再读 resultFile)。'
2839
+ + '需要某工作流的字段清单时从 resultFile 里该元素针对性提取,不要把全量内容搬入上下文。'
2840
+ + '读取本输出时请一次读完:不要用 head/tail 等管道截断(截断会破坏 JSON,并导致重复执行同一命令)。',
2841
+ }, {
2842
+ ...result,
2843
+ countDetails: toArray(result.countDetails).map(todoStatCompactRow),
2844
+ });
2845
+ if (Buffer.byteLength(JSON.stringify(compact), 'utf8') <= TODO_STAT_STDOUT_SAFE_BYTES)
2846
+ return compact;
2847
+ // 极端兜底(分组行极多):只保留路径与总量信息
2848
+ const trimmed = { ...compact, countDetailsTruncated: true };
2849
+ delete trimmed.countDetails;
2850
+ return trimmed;
2851
+ }
2852
+ const workflowTodoStat = {
2853
+ name: 'workflow.todoStat',
2854
+ resource: 'workflow',
2855
+ method: 'todoStat',
2856
+ action: 'workflow.todoStat',
2857
+ summary: '按工作流/参与节点/参与身份三元组统计当前用户待办分组计数(智能分组第一步)',
2858
+ capability: 'search',
2859
+ risk: 'read',
2860
+ inputSchema: schemaOf({
2861
+ category: { const: 'todo' },
2862
+ subCategory: { type: 'string' },
2863
+ tabid: { type: 'string' },
2864
+ tabName: { type: 'string' },
2865
+ creator: { type: 'string' },
2866
+ creatorDept: { type: 'string' },
2867
+ workflowType: { type: 'string' },
2868
+ workflow: { type: 'array', items: { type: 'string' } },
2869
+ requestLevel: { type: 'string' },
2870
+ requestName: { type: 'string' },
2871
+ requestMark: { type: 'string' },
2872
+ createTime: { type: 'string' },
2873
+ receiveTime: { type: 'string' },
2874
+ finishTime: { type: 'string' },
2875
+ filterFields: { type: 'array', items: { type: 'object' } },
2876
+ nodeId: { type: 'string' },
2877
+ requestId: { type: 'string' },
2878
+ exRequestId: { type: 'string' },
2879
+ conditionType: { type: 'string', enum: ['and', 'or'] },
2880
+ sort: { type: 'string' },
2881
+ }, { required: ['category'] }),
2882
+ handler: async (args, env) => {
2883
+ // 版本门禁:todoStat 的统计口径(tabid=0 全部 + 参与身份三元组)只在智能分组模式成立。
2884
+ // 来源:Python 无独立 todoStat operation,只有 cmd_workflow_search_plan 的 smart 门禁 +
2885
+ // execute_workflow_search_legacy 的分组输出——低版本待办统计由 workflow.search 的 legacy 分组输出承担。
2886
+ // 低版本直接拒绝,避免模型拿到"全部(0)"口径统计(与 legacy"待处理(7)"口径矛盾)后按错误口径规划分组。
2887
+ const mode = await resolveSearchMode(env, args);
2888
+ if (mode !== 'smart') {
2889
+ throw workflowValidationError('todo_stat_requires_smart_mode', `workflow.todoStat 仅支持智能分组模式(OA ≥ ${formatOaVersion(SMART_GROUPING_MIN_OA_VERSION)});低版本请直接走 workflow.search(CLI 会自动输出分组统计)`, { retryAllowed: false, renderMode: mode });
2890
+ }
2891
+ const { query } = await buildSearchQuery(env, { ...args, category: 'todo' }, 'stat');
2892
+ const response = await env.host.workflowRequest({
2893
+ method: 'POST',
2894
+ path: WORKFLOW_ENDPOINTS.TODO_GROUP_STAT,
2895
+ operation: 'workflow.todoStat',
2896
+ body: query,
2897
+ });
2898
+ const data = responseData(response);
2899
+ const countDetails = toArray(data?.countDetails ?? []);
2900
+ let total = 0;
2901
+ let draftTotal = 0;
2902
+ let pendingTotal = 0;
2903
+ let beRejectTotal = 0;
2904
+ for (const item of countDetails) {
2905
+ if (!item || typeof item !== 'object')
2906
+ continue;
2907
+ total += asInt(item.totalCount) ?? 0;
2908
+ draftTotal += asInt(item.draftCount) ?? 0;
2909
+ pendingTotal += asInt(item.pendingCount) ?? 0;
2910
+ beRejectTotal += asInt(item.beRejectCount) ?? 0;
2911
+ }
2912
+ return workflowSuccess('workflow.todoStat', await compactTodoStatOutput({
2913
+ type: 'workflowTodoStat',
2914
+ query,
2915
+ total,
2916
+ draftTotal,
2917
+ pendingTotal,
2918
+ beRejectTotal,
2919
+ countDetails,
2920
+ displayRules: [
2921
+ 'countDetails 数组元素按接口原样透传;模型据汇报式分组骨架做分组规划,再分别调用 workflow.search 取明细',
2922
+ '折叠组(≤3 个)只显示数量——数量来自 countDetails 的 totalCount/urgentCount/overtimeCount/forwardToSelfCount/managerForwardToSelfCount 等字段,不需要再走 workflow.search 查数量',
2923
+ 'countDetails 行含 nodeTypeName(节点类型名)、pendingCount(标准待处理身份且非草稿数量)、draftCount(待办列表草稿口径命中数量,flowStatus∈{草稿,待提交})、beRejectCount(被退回数量)。行 totalCount 与三者非严格相等,分组标题主计数仍用 totalCount;新字段用于草稿折叠预判与草稿/退回注记',
2924
+ 'draftTotal/pendingTotal/beRejectTotal 为各分组新计数字段的累加;汇报时可注明其中草稿 M 条、被退回 K 条,但不要据以改写主计数 total',
2925
+ ],
2926
+ }, env));
2927
+ },
2928
+ };
2929
+ // ---------------------------------------------------------------------------
2930
+ // workflow.detail(来源:cmd_workflow_detail / normalize_workflow_detail_response)
2931
+ // ---------------------------------------------------------------------------
2932
+ const workflowDetail = {
2933
+ name: 'workflow.detail',
2934
+ resource: 'workflow',
2935
+ method: 'detail',
2936
+ action: 'workflow.detail',
2937
+ summary: '按 requestId 或列表序号查看流程完整详情(基础信息 + 表单数据 + 签字意见)',
2938
+ capability: 'view',
2939
+ risk: 'read',
2940
+ inputSchema: schemaOf({
2941
+ requestId: SCHEMA_REQUEST_ID,
2942
+ index: SCHEMA_INDEX,
2943
+ group: { type: 'string', description: '分组标识(urgent/overdue/batch/pending),配合 index' },
2944
+ requestLogCurrent: { type: 'integer', minimum: 1, default: 1 },
2945
+ requestLogPageSize: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
2946
+ }),
2947
+ handler: async (args, env) => {
2948
+ const requestId = await resolveTargetRequestId(env, args);
2949
+ const session = await env.host.getSession();
2950
+ const payload = {
2951
+ requestId,
2952
+ isGetRequestBaseInfo: true,
2953
+ isGetRequestLog: false,
2954
+ isGetFormData: true,
2955
+ isReadRequest: true,
2956
+ };
2957
+ if (session.userId)
2958
+ payload.userId = session.userId;
2959
+ const response = await env.host.workflowRequest({
2960
+ method: 'POST',
2961
+ path: WORKFLOW_ENDPOINTS.REQUEST_DETAIL,
2962
+ operation: 'workflow.detail',
2963
+ body: payload,
2964
+ });
2965
+ const normalized = normalizeDetailResponse(response);
2966
+ // 签字意见按页查询(来源:cmd_detail / attach_request_log_page)
2967
+ const logCurrent = asInt(args.requestLogCurrent) ?? 1;
2968
+ const logPageSize = Math.min(asInt(args.requestLogPageSize) ?? 10, 50);
2969
+ const logPage = await fetchRequestLogPage(env, requestId, logCurrent, logPageSize);
2970
+ normalized.requestLog = logPage;
2971
+ normalized.renderMarkdown = renderDetailMarkdown(normalized);
2972
+ return workflowSuccess('workflow.detail', {
2973
+ type: 'workflowDetail',
2974
+ basicInfo: normalized.basicInfo,
2975
+ formData: normalized.formData,
2976
+ requestLog: normalized.requestLog,
2977
+ renderMarkdown: normalized.renderMarkdown,
2978
+ warnings: normalized.warnings,
2979
+ displayRules: [
2980
+ '查看流程内容时必须直接输出单个 Markdown 详情面板,不要放进代码块,不要拆成多个独立卡片,不要输出 Markdown 表格',
2981
+ '基础信息区、表单内容区、签字意见区必须在同一个 Markdown 面板内,用小标题区分,而不是渲染成多个卡片',
2982
+ '流程标题下方不要添加分隔线或下划线;第一条分隔线只能出现在基础信息区之后',
2983
+ '基础信息区展示流程编号、发起人/申请人、发起时间、当前节点、紧急程度和流程状态;字段缺失就不展示',
2984
+ '表单内容区只展示核心业务字段,跳过空值、技术字段、可编辑、操作按钮、岗位、部门、最后反馈时间等非必要字段',
2985
+ '表单字段值必须原样展示 value,不能摘要、改写、删减;字段含 valueHtml/links 时优先渲染为可点击超链接,不要把长 URL 直接作为普通文本换行展示',
2986
+ 'displayMode=longTextBlock 或 fullWidth=true 的表单字段必须全宽展示,保留原文、标点和换行;不要保留接口富文本里的背景色、字号和大段空白',
2987
+ '签字意见是查看流程内容的固定组成部分,必须以意见块展示,不要压成表格或和表单字段混在一起;每条按三行展示(操作人/方式/时间、接收人、签字意见内容)',
2988
+ '签字意见区标题写成「签字意见({{paginationText}})」;只有 requestLog.hasMore=true 时才显示 nextPagePrompt',
2989
+ '不要展示 requestId;不要展示 workflowName,用户明确询问所属工作流时才展示为「工作流名称」;不要翻译、音译或改写接口返回文本',
2990
+ '详情面板后不要输出固定建议列表或「如果想进一步操作」类尾巴;用户要求摘要/总结时改为围绕表单业务内容提炼,不要渲染详情表',
2991
+ ],
2992
+ });
2993
+ },
2994
+ };
2995
+ function normalizeDetailResponse(response) {
2996
+ const record = responseData(response);
2997
+ const data = record?.data && typeof record.data === 'object' ? record.data : {};
2998
+ const base = data.workflowBaseInfo && typeof data.workflowBaseInfo === 'object' ? data.workflowBaseInfo : {};
2999
+ const other = data.otherInfo && typeof data.otherInfo === 'object' ? data.otherInfo : {};
3000
+ const currentNodes = toArray(base.currentNodeList ?? []);
3001
+ const currentNode = currentNodes
3002
+ .map((n) => pickText(n, 'nodeName', 'name'))
3003
+ .filter(Boolean)
3004
+ .join('、');
3005
+ const basicInfo = {
3006
+ requestTitle: pickText(base, 'requestName', 'requestNameTitle') || pickText(other, 'multiReqName'),
3007
+ flowName: pickText(base, 'requestName', 'requestNameTitle') || pickText(other, 'multiReqName'),
3008
+ workflowName: pickText(base, 'workflowName'),
3009
+ requestMark: pickText(base, 'requestmark', 'requestMark', 'requestCode'),
3010
+ creatorName: pickText(base, 'creatorLastName', 'createrName', 'creatorName'),
3011
+ createdAt: pickText(base, 'createDateTime', 'createTime'),
3012
+ currentNode: currentNode || pickText(base, 'currentNodeName', 'nodeName'),
3013
+ requestLevel: pickText(base, 'requestLevelShow', 'requestlevelShow', 'requestLevelTitle', 'requestlevelTitle') || asString(base.requestLevel),
3014
+ flowStatus: pickText(base, 'flowstatusShow', 'status') || asString(base.flowStatus),
3015
+ };
3016
+ const formFields = extractFormFields(data);
3017
+ const formLogs = extractRequestLogs(data);
3018
+ return {
3019
+ basicInfo,
3020
+ formData: {
3021
+ available: formFields.length > 0,
3022
+ fieldCount: formFields.length,
3023
+ fields: formFields,
3024
+ },
3025
+ requestLog: { available: formLogs.length > 0, items: formLogs },
3026
+ warnings: [],
3027
+ };
3028
+ }
3029
+ // 去 HTML 标签 + 反转义(来源:Python strip_html,L681)。模块级共用。
3030
+ function stripHtml(value) {
3031
+ if (value === null || value === undefined)
3032
+ return '';
3033
+ const text = String(value).replace(/<[^>]+>/g, '');
3034
+ const map = {
3035
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'", '&nbsp;': ' ',
3036
+ };
3037
+ return text.replace(/&[a-z]+;/gi, (m) => map[m.toLowerCase()] ?? m).replace(/\s+/g, ' ').trim();
3038
+ }
3039
+ function stringifyDetailValue(value) {
3040
+ if (value === null || value === undefined)
3041
+ return '';
3042
+ if (typeof value === 'string')
3043
+ return stripHtml(value);
3044
+ if (typeof value === 'number' || typeof value === 'boolean')
3045
+ return String(value);
3046
+ if (Array.isArray(value)) {
3047
+ const parts = [];
3048
+ for (const item of value) {
3049
+ const text = item && typeof item === 'object'
3050
+ ? pickText(item, 'name', 'showName', 'label', 'title', 'value', 'content')
3051
+ : stringifyDetailValue(item);
3052
+ if (text)
3053
+ parts.push(text);
3054
+ }
3055
+ return parts.join('、');
3056
+ }
3057
+ if (typeof value === 'object') {
3058
+ const text = pickText(value, 'showValue', 'displayValue', 'name', 'showName', 'label', 'title', 'value', 'content');
3059
+ if (text)
3060
+ return text;
3061
+ if (Array.isArray(value.browserDatas))
3062
+ return stringifyDetailValue(value.browserDatas);
3063
+ }
3064
+ return '';
3065
+ }
3066
+ function stringifyDetailDataText(value) {
3067
+ if (!value || typeof value !== 'object')
3068
+ return stripHtml(value);
3069
+ for (const key of ['content', 'richContent', 'text', 'value', 'displayValue', 'showValue']) {
3070
+ const raw = value[key];
3071
+ if (raw !== undefined && raw !== null && raw !== '' && !(Array.isArray(raw) && raw.length === 0) && !(typeof raw === 'object' && Object.keys(raw).length === 0)) {
3072
+ const text = stripHtml(raw);
3073
+ if (text)
3074
+ return text;
3075
+ }
3076
+ }
3077
+ return '';
3078
+ }
3079
+ // 详情表单字段容器:data 下所有键名含 "form" 且非空的键(来源:Python detail_form_source_keys,L2029)。
3080
+ function detailFormSourceKeys(data) {
3081
+ if (!data || typeof data !== 'object')
3082
+ return [];
3083
+ return Object.keys(data).filter((k) => {
3084
+ if (typeof k !== 'string' || !k.toLowerCase().includes('form'))
3085
+ return false;
3086
+ const v = data[k];
3087
+ if (v === null || v === '' || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && Object.keys(v).length === 0))
3088
+ return false;
3089
+ return true;
3090
+ });
3091
+ }
3092
+ function detailFormFieldValue(obj, formField) {
3093
+ const dataKey = pickText(formField, 'dataKey', 'fieldKey', 'key');
3094
+ const fieldId = pickText(formField, 'id', 'fieldId');
3095
+ const fieldName = pickText(formField, 'title', 'fieldName', 'name', 'label');
3096
+ const formData = obj?.formData;
3097
+ if (formData && typeof formData === 'object') {
3098
+ for (const k of [dataKey, fieldId, fieldName]) {
3099
+ if (k && k in formData) {
3100
+ const v = stringifyDetailValue(formData[k]);
3101
+ if (v)
3102
+ return v;
3103
+ }
3104
+ }
3105
+ }
3106
+ const dataOptions = obj?.dataOptions;
3107
+ if (Array.isArray(dataOptions) && dataOptions.length) {
3108
+ const v = stringifyDetailValue(dataOptions);
3109
+ if (v)
3110
+ return v;
3111
+ }
3112
+ const dataText = obj?.dataText;
3113
+ if (dataText && typeof dataText === 'object') {
3114
+ const v = stringifyDetailDataText(dataText);
3115
+ if (v)
3116
+ return v;
3117
+ }
3118
+ for (const k of ['showValue', 'displayValue', 'fieldValue', 'value', 'content', 'text']) {
3119
+ if (k in obj) {
3120
+ const v = stringifyDetailValue(obj[k]);
3121
+ if (v)
3122
+ return v;
3123
+ }
3124
+ }
3125
+ return '';
3126
+ }
3127
+ // 递归收集:命中 obj.formField({title/fieldName/name/label})即为一个表单字段(来源:Python
3128
+ // collect_detail_form_fields_from_obj,L2040),并继续下钻 dataDetails/details/mainData/... 等容器。
3129
+ function collectDetailFormFields(obj, fields, limit = 80) {
3130
+ if (fields.length >= limit)
3131
+ return;
3132
+ if (Array.isArray(obj)) {
3133
+ for (const item of obj)
3134
+ collectDetailFormFields(item, fields, limit);
3135
+ return;
3136
+ }
3137
+ if (!obj || typeof obj !== 'object')
3138
+ return;
3139
+ const formField = obj.formField;
3140
+ if (formField && typeof formField === 'object') {
3141
+ const fieldName = pickText(formField, 'title', 'fieldName', 'name', 'label');
3142
+ if (fieldName) {
3143
+ fields.push({
3144
+ fieldId: pickText(formField, 'id', 'fieldId'),
3145
+ fieldName,
3146
+ componentKey: pickText(formField, 'componentKey', 'type'),
3147
+ value: detailFormFieldValue(obj, formField),
3148
+ });
3149
+ }
3150
+ }
3151
+ for (const key of ['dataDetails', 'details', 'mainData', 'detailData', 'subData', 'rows', 'children', 'items', 'list']) {
3152
+ if (key in obj) {
3153
+ collectDetailFormFields(obj[key], fields, limit);
3154
+ }
3155
+ }
3156
+ }
3157
+ function extractFormFields(data) {
3158
+ const sourceKeys = detailFormSourceKeys(data);
3159
+ const fields = [];
3160
+ for (const key of sourceKeys) {
3161
+ collectDetailFormFields(data[key], fields);
3162
+ if (fields.length >= 80)
3163
+ break;
3164
+ }
3165
+ return fields;
3166
+ }
3167
+ function extractRequestLogs(data) {
3168
+ const logKeys = ['requestLogList', 'requestLogs', 'logs', 'logList', 'signList', 'records'];
3169
+ const out = [];
3170
+ for (const key of logKeys) {
3171
+ const arr = toArray(data?.[key]);
3172
+ if (arr.length)
3173
+ out.push(...arr.map((l) => normalizeLogItem(l)).filter((l) => l && detailLogHasContent(l)));
3174
+ }
3175
+ return out;
3176
+ }
3177
+ function detailLogHasContent(log) {
3178
+ return Boolean(log && (log.operator || log.action || log.time || log.opinion || log.receivers));
3179
+ }
3180
+ // ---------------------------------------------------------------------------
3181
+ // 签字意见归一化(来源:normalize_detail_log_item / fetch_request_log_page)
3182
+ // ---------------------------------------------------------------------------
3183
+ async function fetchRequestLogPage(env, requestId, current, pageSize) {
3184
+ const session = await env.host.getSession();
3185
+ const payload = { requestId, current: Math.max(1, current), pageSize: Math.max(1, Math.min(50, pageSize)) };
3186
+ if (session.userId)
3187
+ payload.userId = session.userId;
3188
+ const response = await env.host.workflowRequest({
3189
+ method: 'POST',
3190
+ path: WORKFLOW_ENDPOINTS.REQUEST_LOG,
3191
+ operation: 'workflow.requestLog',
3192
+ body: payload,
3193
+ });
3194
+ const data = responseData(response);
3195
+ const items = toArray(data?.data?.list ?? data?.data?.records ?? data?.list ?? data?.records ?? data?.records ?? [])
3196
+ .map((l) => normalizeLogItem(l))
3197
+ .filter((l) => detailLogHasContent(l));
3198
+ const total = extractTotal(response);
3199
+ const totalReliable = total !== null;
3200
+ const hasMore = asBool(data?.hasMore ?? data?.data?.hasMore, false) ||
3201
+ (totalReliable ? current * pageSize < total : items.length >= pageSize);
3202
+ return {
3203
+ available: items.length > 0,
3204
+ current,
3205
+ pageSize,
3206
+ currentPageCount: items.length,
3207
+ total,
3208
+ totalReliable,
3209
+ hasMore,
3210
+ nextCurrent: hasMore ? current + 1 : null,
3211
+ nextPagePrompt: hasMore ? `可继续查看第 ${current + 1} 页签字意见` : '',
3212
+ items,
3213
+ paginationText: `第 ${current} 页,每页 ${pageSize} 条`,
3214
+ paginationNote: '签字意见按页展示;只有 hasMore=true 时才提示查看下一页',
3215
+ };
3216
+ }
3217
+ function normalizeLogItem(item) {
3218
+ if (!item || typeof item !== 'object')
3219
+ return {};
3220
+ const operator = pickText(item, 'operatorName', 'operator', 'operatorUser', 'userName', 'handlerName', 'creatorName', 'handleUser');
3221
+ const action = pickText(item, 'commentName', 'operateType', 'operateTypeName', 'operationType', 'actionName', 'typeName', 'status', 'statusName');
3222
+ const time = pickText(item, 'operateTime', 'operateDateTime', 'handlerTime', 'createTime', 'createdatetime', 'createDateTime', 'time');
3223
+ let opinion = pickText(item, 'opinion', 'remark', 'signRemark', 'content', 'comment', 'message');
3224
+ const receivers = pickText(item, 'receiverName', 'receivers', 'receivePersonName', 'receiver');
3225
+ const operatorDisplay = operator || '未返回';
3226
+ const actionDisplay = action || '未返回';
3227
+ const timeDisplay = time || '未返回';
3228
+ const opinionDisplay = opinion || '未填写意见';
3229
+ return {
3230
+ operator,
3231
+ operatorDisplay,
3232
+ time,
3233
+ timeDisplay,
3234
+ action,
3235
+ actionDisplay,
3236
+ operatorActionTime: `${operatorDisplay} · ${actionDisplay} · ${timeDisplay}`,
3237
+ receiverDisplay: receivers || '未返回',
3238
+ opinion,
3239
+ opinionDisplay,
3240
+ displayLines: {
3241
+ line1: `${operatorDisplay} · ${actionDisplay} · ${timeDisplay}`,
3242
+ line2: `接收人:${receivers || '未返回'}`,
3243
+ line3: opinionDisplay,
3244
+ },
3245
+ displayLayout: 'threeLineSignatureOpinion',
3246
+ };
3247
+ }
3248
+ /** 详情字段取值:有 links 时优先渲染为可点击超链接(来源:Python detail_field_markdown_value) */
3249
+ function detailFieldMarkdownValue(field) {
3250
+ const links = field.links;
3251
+ if (Array.isArray(links) && links.length) {
3252
+ const rendered = links
3253
+ .map((link) => {
3254
+ if (!link || typeof link !== 'object')
3255
+ return '';
3256
+ const url = asString(link.url) ?? asString(link.href) ?? '';
3257
+ const label = asString(link.label) ?? asString(link.name) ?? '查看链接';
3258
+ return url ? `[${label}](${url})` : '';
3259
+ })
3260
+ .filter(Boolean);
3261
+ if (rendered.length)
3262
+ return rendered.join('、');
3263
+ }
3264
+ return asString(field.value) ?? '';
3265
+ }
3266
+ function renderDetailMarkdown(normalized) {
3267
+ const basic = normalized.basicInfo || {};
3268
+ const lines = [`## ${asString(basic.flowName) || asString(basic.requestTitle) || '流程详情'}`, '', '### 基础信息'];
3269
+ const baseItems = [
3270
+ ['流程编号', basic.requestMark],
3271
+ ['发起人', basic.creatorName],
3272
+ ['发起时间', basic.createdAt],
3273
+ ['当前节点', basic.currentNode],
3274
+ ['紧急程度', basic.requestLevel],
3275
+ ['流程状态', basic.flowStatus],
3276
+ ];
3277
+ for (const [label, value] of baseItems) {
3278
+ const text = asString(value);
3279
+ if (text)
3280
+ lines.push(`- **${label}**:${text}`);
3281
+ }
3282
+ lines.push('', '---', '', '### 表单内容');
3283
+ const fields = normalized.formData?.fields || [];
3284
+ if (fields.length) {
3285
+ for (const f of fields) {
3286
+ const name = asString(f.fieldName);
3287
+ const value = detailFieldMarkdownValue(f);
3288
+ if (!name || !value)
3289
+ continue;
3290
+ // 长文本字段全宽展示:值用轻量正文阅读区承载,保留原文、标点和换行
3291
+ if (f.displayMode === 'longTextBlock' || f.fullWidth === true || value.includes('\n')) {
3292
+ const block = value.split('\n').map((part) => (part ? ` > ${part}` : ' >')).join('\n');
3293
+ lines.push(`- **${name}**:`, '', block);
3294
+ }
3295
+ else {
3296
+ lines.push(`- **${name}**:${value}`);
3297
+ }
3298
+ }
3299
+ }
3300
+ else {
3301
+ lines.push('- 本次详情接口未返回可展示的表单字段。');
3302
+ }
3303
+ const log = normalized.requestLog || {};
3304
+ const pagination = asString(log.paginationText) || '第 1 页,每页 10 条';
3305
+ lines.push('', '---', '', `### 签字意见(${pagination})`);
3306
+ const logItems = log.items || [];
3307
+ if (logItems.length) {
3308
+ for (const it of logItems) {
3309
+ const dl = it.displayLines || {};
3310
+ let line1 = asString(dl.line1) ?? '';
3311
+ let line2 = asString(dl.line2) ?? '';
3312
+ let line3 = asString(dl.line3) ?? '';
3313
+ if (!line1) {
3314
+ const operator = asString(it.operatorDisplay) ?? '未返回';
3315
+ const action = asString(it.actionDisplay) ?? '未返回';
3316
+ const timeText = asString(it.timeDisplay) ?? asString(it.createdAt) ?? '未返回';
3317
+ line1 = `${operator} · ${action} · ${timeText}`;
3318
+ }
3319
+ if (!line2)
3320
+ line2 = `接收人:${asString(it.receiverDisplay) ?? '未返回'}`;
3321
+ if (!line3) {
3322
+ const opinion = asString(it.opinionDisplay) ?? asString(it.contentDisplay) ?? asString(it.content) ?? '未填写意见';
3323
+ line3 = `签字意见:${opinion}`;
3324
+ }
3325
+ lines.push(`> **${line1}**`, '>', `> ${line2}`, '>', `> ${line3}`, '>');
3326
+ }
3327
+ if (lines[lines.length - 1] === '>')
3328
+ lines.pop();
3329
+ }
3330
+ else {
3331
+ lines.push('- 本页没有签字意见/流转日志。');
3332
+ }
3333
+ if (log.hasMore === true && log.nextPagePrompt)
3334
+ lines.push('', asString(log.nextPagePrompt) ?? '');
3335
+ return lines.join('\n').trim();
3336
+ }
3337
+ // ---------------------------------------------------------------------------
3338
+ // workflow.summary(来源:cmd_workflow_summary / select_detail_summary_fields / generate_business_summary)
3339
+ // ---------------------------------------------------------------------------
3340
+ const workflowSummary = {
3341
+ name: 'workflow.summary',
3342
+ resource: 'workflow',
3343
+ method: 'summary',
3344
+ action: 'workflow.summary',
3345
+ summary: '按 requestId 或列表序号生成流程业务摘要上下文(不标记已读)',
3346
+ capability: 'view',
3347
+ risk: 'read',
3348
+ inputSchema: schemaOf({
3349
+ requestId: SCHEMA_REQUEST_ID,
3350
+ index: SCHEMA_INDEX,
3351
+ group: { type: 'string' },
3352
+ summaryFields: { type: 'integer', minimum: 1, default: 12 },
3353
+ }),
3354
+ handler: async (args, env) => {
3355
+ const requestId = await resolveTargetRequestId(env, args);
3356
+ const session = await env.host.getSession();
3357
+ const payload = {
3358
+ requestId,
3359
+ isGetRequestBaseInfo: true,
3360
+ isGetRequestLog: false,
3361
+ isGetFormData: true,
3362
+ isReadRequest: false,
3363
+ };
3364
+ if (session.userId)
3365
+ payload.userId = session.userId;
3366
+ const response = await env.host.workflowRequest({
3367
+ method: 'POST',
3368
+ path: WORKFLOW_ENDPOINTS.REQUEST_DETAIL,
3369
+ operation: 'workflow.summary',
3370
+ body: payload,
3371
+ });
3372
+ const normalized = normalizeDetailResponse(response);
3373
+ const limit = Math.max(1, asInt(args.summaryFields) ?? 12);
3374
+ const summaryFields = selectSummaryFields(normalized.formData.fields, limit);
3375
+ const summaryText = generateBusinessSummary(summaryFields);
3376
+ return workflowSuccess('workflow.summary', {
3377
+ type: 'workflowSummary',
3378
+ requestId,
3379
+ basicInfo: normalized.basicInfo,
3380
+ summaryFields,
3381
+ summary: summaryText,
3382
+ formDataAvailable: normalized.formData.available,
3383
+ });
3384
+ },
3385
+ };
3386
+ // 摘要字段过滤(来源:Python DETAIL_SUMMARY_* 常量与 select_detail_summary_fields,L862-894 / L989)
3387
+ const DETAIL_SUMMARY_EXCLUDED_TITLES = new Set([
3388
+ '流程名称', '所属工作流', '流程编号', '发起人', '提交人', '创建人', '制单人', '填报人', '申请人',
3389
+ '发起时间', '提交时间', '创建时间', '当前节点', '当前审批节点', '当前处理节点', '审批节点', '处理节点',
3390
+ '流转节点', '流程节点', '节点名称', '紧急程度', '流程状态', '审批状态', '待办状态', '当前状态', '状态',
3391
+ '停留时长', '停留时间', '文档', '附件', '相关文档', '相关附件', '相关客户', '相关项目', '相关人员',
3392
+ '相关供应商', '相关订单', '相关合同', '相关人员信息', '相关商品', '相关产品',
3393
+ ]);
3394
+ const SUMMARY_AMOUNT_FIELD_PATTERN = /(金额|总额|合计|预算|成本|收入|付款|收款|借款|费用总计|费用合计)/;
3395
+ const SUMMARY_REASON_FIELD_PATTERN = /(事由|原因|说明|概述|描述|内容|背景|价值|需求|事项)/;
3396
+ const SUMMARY_TYPE_FIELD_PATTERN = /(类型|类别|分类|方式|性质|用途)$/;
3397
+ const SUMMARY_DATE_FIELD_PATTERN = /(日期|时间|预计)/;
3398
+ const SUMMARY_DAY_FIELD_PATTERN = /(天数|人天|工时|时长|期限|周期)$/;
3399
+ const SUMMARY_PERSON_FIELD_PATTERN = /^(负责人|责任人|主管|审批人|经手人)$/;
3400
+ const CORE_FIELD_PATTERNS = [
3401
+ SUMMARY_REASON_FIELD_PATTERN, SUMMARY_AMOUNT_FIELD_PATTERN, SUMMARY_TYPE_FIELD_PATTERN,
3402
+ SUMMARY_DATE_FIELD_PATTERN, SUMMARY_DAY_FIELD_PATTERN, SUMMARY_PERSON_FIELD_PATTERN,
3403
+ ];
3404
+ const DETAIL_SUMMARY_NOISE_FIELD_PATTERN = /文档|附件|链接|地址|url|URL/;
3405
+ const DETAIL_SUMMARY_BOOLEAN_FIELD_PATTERN = /是否|开关|启用/;
3406
+ const DETAIL_SUMMARY_RELATED_FIELD_PATTERN = /^相关/;
3407
+ const DETAIL_SUMMARY_PERSON_ROLE_PATTERN = /(负责人|责任人|处理人|经手人|审批人|接收人|联系人|办理人)/;
3408
+ const DETAIL_SUMMARY_META_TIME_PATTERN = /(?:最晚|期望|要求|承诺|截止|时限|期限|截至|提交|创建|更新|发起)\S{0,10}?(?:时间|日期|时限|期限)/;
3409
+ const DETAIL_SUMMARY_BOOL_QUESTION_PATTERN = /^(是否|是否为|是否包含|有无|是否需要)/;
3410
+ const DETAIL_SUMMARY_REGISTER_META_PATTERN = /(备案|环境|版本|基线|编号|序号|编码|流水号|工单|紧急|优先级|级别|程度|渠道|来源|状态)/;
3411
+ const DETAIL_SUMMARY_WORKFLOW_META_PATTERN = /^(发起|提交|创建|制单|填报).*(人|时间)$|(当前|审批|处理|流转|流程|待办).*(节点|状态)|(停留|超时|到达当前节点).*(时间|时长)|^(节点|状态|审批中|已结束|草稿)$/;
3412
+ const SUMMARY_MEANINGLESS_VALUES = new Set(['0', '无', '暂无', '无内容', '-', '--', 'null', 'none', 'undefined', '请填写', '请选择']);
3413
+ function normalizeDetailSummaryValue(_name, value) {
3414
+ let text = stripHtml(value);
3415
+ text = text.replace(/https?:\/\/\S+/gi, '').trim();
3416
+ text = text.replace(/\s+/g, ' ').replace(/[::;;,.,。]/g, '').trim();
3417
+ if (text.length > 180)
3418
+ text = text.slice(0, 180);
3419
+ return text;
3420
+ }
3421
+ function isMeaninglessSummaryValue(value) {
3422
+ const text = (value || '').trim();
3423
+ if (!text)
3424
+ return true;
3425
+ if (SUMMARY_MEANINGLESS_VALUES.has(text.toLowerCase()))
3426
+ return true;
3427
+ try {
3428
+ return parseFloat(text) === 0.0;
3429
+ }
3430
+ catch {
3431
+ return false;
3432
+ }
3433
+ }
3434
+ function looksLikeUrlOrDoc(value) {
3435
+ const text = stripHtml(value);
3436
+ if (!text)
3437
+ return false;
3438
+ if (/^https?:\/\/\S+$/i.test(text))
3439
+ return true;
3440
+ if (!/https?:\/\/|\/doc\/share\/|\/share\/view\/|fileId=/i.test(text))
3441
+ return false;
3442
+ const stripped = text.replace(/https?:\/\/\S+/gi, '').trim().replace(/[::;;,.,。"'“” \t\r\n]/g, '');
3443
+ return stripped.length < 20;
3444
+ }
3445
+ function selectSummaryFields(fields, limit = 12) {
3446
+ const candidates = [];
3447
+ const seen = new Set();
3448
+ const collectField = (field) => {
3449
+ const name = stripHtml(field?.fieldName ?? '');
3450
+ const rawValue = stripHtml(field?.value ?? '');
3451
+ const value = normalizeDetailSummaryValue(name, rawValue);
3452
+ if (!name || !value)
3453
+ return false;
3454
+ if (isMeaninglessSummaryValue(rawValue))
3455
+ return false;
3456
+ if (DETAIL_SUMMARY_EXCLUDED_TITLES.has(name))
3457
+ return false;
3458
+ if (DETAIL_SUMMARY_RELATED_FIELD_PATTERN.test(name))
3459
+ return false;
3460
+ if (DETAIL_SUMMARY_WORKFLOW_META_PATTERN.test(name))
3461
+ return false;
3462
+ if (DETAIL_SUMMARY_PERSON_ROLE_PATTERN.test(name))
3463
+ return false;
3464
+ if (DETAIL_SUMMARY_META_TIME_PATTERN.test(name))
3465
+ return false;
3466
+ if (DETAIL_SUMMARY_BOOL_QUESTION_PATTERN.test(name))
3467
+ return false;
3468
+ if (DETAIL_SUMMARY_REGISTER_META_PATTERN.test(name))
3469
+ return false;
3470
+ if (looksLikeUrlOrDoc(rawValue))
3471
+ return false;
3472
+ if (DETAIL_SUMMARY_NOISE_FIELD_PATTERN.test(name))
3473
+ return false;
3474
+ if (DETAIL_SUMMARY_BOOLEAN_FIELD_PATTERN.test(name) && ['是', '否', '有', '无', 'true', 'false', 'True', 'False'].includes(value))
3475
+ return false;
3476
+ const key = `${name}:${value}`;
3477
+ if (seen.has(key))
3478
+ return false;
3479
+ seen.add(key);
3480
+ candidates.push({ fieldId: asString(field?.fieldId), fieldName: name, componentKey: asString(field?.componentKey), value });
3481
+ return true;
3482
+ };
3483
+ for (const field of fields) {
3484
+ if (CORE_FIELD_PATTERNS.some((p) => p.test(stripHtml(field?.fieldName ?? '')))) {
3485
+ if (candidates.length >= limit)
3486
+ break;
3487
+ collectField(field);
3488
+ }
3489
+ }
3490
+ for (const field of fields) {
3491
+ if (CORE_FIELD_PATTERNS.some((p) => p.test(stripHtml(field?.fieldName ?? ''))))
3492
+ continue;
3493
+ if (candidates.length >= limit)
3494
+ break;
3495
+ collectField(field);
3496
+ }
3497
+ return candidates;
3498
+ }
3499
+ function generateBusinessSummary(fields) {
3500
+ if (!fields.length)
3501
+ return '该流程暂无可用表单数据,暂时无法生成摘要';
3502
+ return fields.map((f) => `${f.fieldName}:${f.value}`).join(';');
3503
+ }
3504
+ // ---------------------------------------------------------------------------
3505
+ // workflow.open(来源:cmd_workflow_open / build_workflow_open_url)
3506
+ // ---------------------------------------------------------------------------
3507
+ const workflowOpen = {
3508
+ name: 'workflow.open',
3509
+ resource: 'workflow',
3510
+ method: 'open',
3511
+ action: 'workflow.open',
3512
+ summary: '按 requestId 或列表序号生成流程详情打开动作(URL + clientAction)',
3513
+ capability: 'view',
3514
+ risk: 'read',
3515
+ inputSchema: schemaOf({
3516
+ requestId: SCHEMA_REQUEST_ID,
3517
+ index: SCHEMA_INDEX,
3518
+ group: { type: 'string' },
3519
+ openMode: { type: 'string', enum: ['auto', 'localBrowser', 'clientAction', 'url'], default: 'auto' },
3520
+ noOpen: { type: 'boolean', default: false },
3521
+ }),
3522
+ handler: async (args, env) => {
3523
+ const requestId = await resolveTargetRequestId(env, args);
3524
+ const session = await env.host.getSession();
3525
+ const baseUrl = (session.baseUrl || '').replace(/\/+$/, '');
3526
+ if (!baseUrl) {
3527
+ throw workflowValidationError('missing_oa_base_url', '打开流程需要 OA 前端地址(session.baseUrl 为空),请先确认 E10 登录域名。');
3528
+ }
3529
+ const url = `${baseUrl}/sp/workflow/flowpage/view/${encodeURIComponent(String(requestId))}`;
3530
+ const openMode = asString(args.openMode) || 'auto';
3531
+ const noOpen = asBool(args.noOpen, false);
3532
+ const requiresHostAction = openMode === 'clientAction' || (openMode === 'auto' && noOpen === false);
3533
+ return workflowSuccess('workflow.open', {
3534
+ success: true,
3535
+ requestId,
3536
+ openMode,
3537
+ url,
3538
+ pathTemplate: '/sp/workflow/flowpage/view/{requestId}',
3539
+ networkRequest: false,
3540
+ requiresHostAction,
3541
+ clientAction: { type: 'openUrl', url },
3542
+ runtimeMode: session.agentType ? 'internal' : 'third_party',
3543
+ baseUrl,
3544
+ });
3545
+ },
3546
+ };
3547
+ // ---------------------------------------------------------------------------
3548
+ // workflow.requestLog(来源:cmd_request_log / fetch_request_log_page)
3549
+ // ---------------------------------------------------------------------------
3550
+ const workflowRequestLog = {
3551
+ name: 'workflow.requestLog',
3552
+ resource: 'workflow',
3553
+ method: 'requestLog',
3554
+ action: 'workflow.requestLog',
3555
+ summary: '按页查询流程签字意见/流转日志',
3556
+ capability: 'view',
3557
+ risk: 'read',
3558
+ inputSchema: schemaOf({
3559
+ requestId: SCHEMA_REQUEST_ID,
3560
+ current: { type: 'integer', minimum: 1, default: 1 },
3561
+ pageSize: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
3562
+ }, { required: ['requestId'] }),
3563
+ handler: async (args, env) => {
3564
+ const requestId = asString(args.requestId);
3565
+ if (!requestId) {
3566
+ throw workflowValidationError('request_id_required', 'workflow.requestLog 必须提供 requestId');
3567
+ }
3568
+ const logPage = await fetchRequestLogPage(env, requestId, asInt(args.current) ?? 1, Math.min(asInt(args.pageSize) ?? 10, 50));
3569
+ return workflowSuccess('workflow.requestLog', {
3570
+ success: true,
3571
+ type: 'workflowRequestLogPage',
3572
+ requestLog: logPage,
3573
+ displayRules: [
3574
+ '签字意见按页展示,不要一次性拉取所有页',
3575
+ '渲染签字意见区标题时写成"签字意见({{paginationText}})"',
3576
+ '只有 hasMore=true 时,才在签字意见区内显示 nextPagePrompt',
3577
+ ],
3578
+ });
3579
+ },
3580
+ };
3581
+ // ---------------------------------------------------------------------------
3582
+ // workflow.pageOpen(来源:cmd_page_open / COMMON_OA_PAGES)
3583
+ // ---------------------------------------------------------------------------
3584
+ const COMMON_OA_PAGES = {
3585
+ center: 'workflow/list/center',
3586
+ todo: 'workflow/list/todo',
3587
+ done: 'workflow/list/done',
3588
+ mine: 'workflow/list/mine',
3589
+ share: 'workflow/list/share',
3590
+ attention: 'workflow/list/attention',
3591
+ draft: 'workflow/list/draft',
3592
+ subordinates: 'workflow/list/subordinates',
3593
+ all: 'workflow/list/all',
3594
+ newflow: 'workflow/newflow',
3595
+ monitor: 'workflow/list/monitor',
3596
+ };
3597
+ // 常用 OA 页面的中文名(来源:Python COMMON_OA_PAGE_LABELS)
3598
+ const COMMON_OA_PAGE_LABELS = {
3599
+ center: '流程首页',
3600
+ todo: '待办流程列表',
3601
+ done: '已办流程列表',
3602
+ mine: '我发起的流程列表',
3603
+ share: '共享流程列表',
3604
+ attention: '关注流程列表',
3605
+ draft: '草稿流程列表',
3606
+ subordinates: '下属待办列表',
3607
+ all: '全部流程列表',
3608
+ newflow: '提交申请列表(显示所有可发起流程的页面)',
3609
+ monitor: '流程监控列表',
3610
+ };
3611
+ const workflowPageOpen = {
3612
+ name: 'workflow.pageOpen',
3613
+ resource: 'workflow',
3614
+ method: 'pageOpen',
3615
+ action: 'workflow.pageOpen',
3616
+ summary: '生成常用 OA 页面(待办/已办/我发起/草稿/监控等)或动态页(create/view)的打开地址',
3617
+ capability: 'page',
3618
+ risk: 'read',
3619
+ inputSchema: schemaOf({
3620
+ page: { type: 'string', description: '页面 key:center/todo/done/mine/share/attention/draft/subordinates/all/newflow/monitor;动态页 create(配合 workflowId)、view(配合 requestId)' },
3621
+ workflowId: { type: 'string', description: '打开发起流程页(page=create)时的工作流 ID' },
3622
+ requestId: { type: 'string', description: '打开流程详情页(page=view)时的流程 requestId' },
3623
+ openMode: { type: 'string', enum: ['auto', 'localBrowser', 'clientAction', 'url'], default: 'auto' },
3624
+ noOpen: { type: 'boolean', default: false },
3625
+ }, { required: ['page'] }),
3626
+ handler: async (args, env) => {
3627
+ const session = await env.host.getSession();
3628
+ const baseUrl = (session.baseUrl || '').replace(/\/+$/, '');
3629
+ if (!baseUrl) {
3630
+ throw workflowValidationError('missing_oa_base_url', '打开 OA 页面需要 OA 前端地址(session.baseUrl 为空),请先确认 E10 登录域名。');
3631
+ }
3632
+ const page = (asString(args.page) || '').toLowerCase();
3633
+ let url;
3634
+ let pageLabel;
3635
+ if (COMMON_OA_PAGES[page]) {
3636
+ url = `${baseUrl}/${COMMON_OA_PAGES[page]}`;
3637
+ pageLabel = COMMON_OA_PAGE_LABELS[page] ?? page;
3638
+ }
3639
+ else if (page === 'create' || page === 'new' || page === '发起流程') {
3640
+ const workflowId = asString(args.workflowId);
3641
+ if (!workflowId) {
3642
+ throw workflowValidationError('workflow_id_required', '打开发起流程页(page=create)必须提供 workflowId');
3643
+ }
3644
+ url = `${baseUrl}/sp/workflow/flowpage/create/${encodeURIComponent(workflowId)}`;
3645
+ pageLabel = '发起流程/新建流程';
3646
+ }
3647
+ else if (page === 'view' || page === 'detail' || page === '详情') {
3648
+ const requestId = asString(args.requestId);
3649
+ if (!requestId) {
3650
+ throw workflowValidationError('request_id_required', '打开流程详情页(page=view)必须提供 requestId');
3651
+ }
3652
+ url = `${baseUrl}/sp/workflow/flowpage/view/${encodeURIComponent(requestId)}`;
3653
+ pageLabel = '流程详情';
3654
+ }
3655
+ else {
3656
+ throw workflowValidationError('page_unknown', `未知的 page:${page}`, { available: Object.keys(COMMON_OA_PAGES).concat(['create', 'view']) });
3657
+ }
3658
+ const openMode = asString(args.openMode) || 'auto';
3659
+ const noOpen = asBool(args.noOpen, false);
3660
+ const requiresHostAction = openMode === 'clientAction' || (openMode === 'auto' && !noOpen);
3661
+ return workflowSuccess('workflow.pageOpen', {
3662
+ success: true,
3663
+ page,
3664
+ pageLabel,
3665
+ url,
3666
+ requiresHostAction,
3667
+ clientAction: { type: 'openUrl', url },
3668
+ baseUrl,
3669
+ });
3670
+ },
3671
+ };
3672
+ // ---------------------------------------------------------------------------
3673
+ // workflow.memoryPrompt(来源:cmd_memory_prompt / memory_chat_params)
3674
+ // ---------------------------------------------------------------------------
3675
+ const workflowMemoryPrompt = {
3676
+ name: 'workflow.memoryPrompt',
3677
+ resource: 'workflow',
3678
+ method: 'memoryPrompt',
3679
+ action: 'workflow.memoryPrompt',
3680
+ summary: '读取用户记忆提示词(工作流消歧、摘要字段优先级、展示偏好等);顺带返回 OA 版本与当前用户身份(岗位/部门/分部),无需再单独查询',
3681
+ capability: 'memory',
3682
+ risk: 'read',
3683
+ inputSchema: schemaOf({
3684
+ workflowId: { type: 'string', description: '工作流维度记忆的 workflowId(可选)' },
3685
+ memoryAppId: { type: 'string', description: '记忆应用 ID(可选;默认取 E10_MEMORY_APP_ID 或内置默认值)' },
3686
+ dynamicContext: { type: 'object', description: '动态上下文参数(可选,合并进 dynamicContextParams)' },
3687
+ }),
3688
+ handler: async (args, env) => {
3689
+ // 记忆接口契约(来源:Python memory_chat_params):
3690
+ // { appid: <记忆应用 ID>, dynamicContextParams: { workflowId?, ...额外上下文 } }
3691
+ // 缺 appid 时接口判为无效应用,直接返回空 promptText(available=false)——
3692
+ // 表现为「记忆永远读不到」,用户的展示偏好/别名/字段偏好全部丢失。
3693
+ const workflowId = asString(args.workflowId);
3694
+ const dynamicContextParams = {};
3695
+ if (args.dynamicContext && typeof args.dynamicContext === 'object') {
3696
+ Object.assign(dynamicContextParams, args.dynamicContext);
3697
+ }
3698
+ if (workflowId)
3699
+ dynamicContextParams.workflowId = workflowId;
3700
+ const payload = {
3701
+ appid: resolveMemoryAppId(asString(args.memoryAppId)),
3702
+ dynamicContextParams,
3703
+ };
3704
+ const response = await env.host.workflowRequest({
3705
+ method: 'POST',
3706
+ path: WORKFLOW_ENDPOINTS.MEMORY_PROMPT,
3707
+ operation: 'workflow.memoryPrompt',
3708
+ body: payload,
3709
+ });
3710
+ // 解包层数(关键):该接口原始返回是 {code,msg,status,data:"<markdown>",fail},
3711
+ // 业务成功标志是 status/fail **不是 success**。此处**不能**用 responseData 解包
3712
+ // (会把 data 里的 markdown 当主体,导致 promptText 恒空)。
3713
+ const body = (response && typeof response === 'object' ? response : {});
3714
+ let promptText = '';
3715
+ if (typeof body.data === 'string')
3716
+ promptText = body.data;
3717
+ else if (typeof body.msg === 'string')
3718
+ promptText = body.msg;
3719
+ const businessOk = body.fail !== true && body.status !== false && body.success !== false;
3720
+ const result = {
3721
+ type: 'memoryPrompt',
3722
+ available: Boolean(businessOk && promptText),
3723
+ promptText,
3724
+ workflowId: workflowId || '',
3725
+ memoryAppId: payload.appid,
3726
+ memoryNotSupported: String(body?.stopReason || '') === 'memory_not_supported',
3727
+ displayRules: [
3728
+ '记忆提示词只作为内部上下文使用,不要原文展示给用户',
3729
+ '如果本轮决策使用了历史记忆(工作流消歧/展示偏好/每页条数/关注字段/审批建议),最终回复必须用自然语言简要说明依据了哪类历史偏好或规则',
3730
+ '内部记忆读取失败或 available=false 时,把本次会话当作新用户继续处理,不追问、不重试',
3731
+ '记忆偏好中的「每页条数」用于列表查询的 pageSize;本次用户明确指定的条数优先于记忆偏好',
3732
+ '记忆与当前系统解析结果冲突时以当前系统为准(流程改名/ID 变更不套用过期记忆标签)',
3733
+ ],
3734
+ };
3735
+ if (!result.available)
3736
+ result.diagnosis = { code: body?.code, status: body?.status, fail: body?.fail, dataEmpty: !promptText };
3737
+ // oaContext:OA 版本 + 当前用户身份(岗位/部门/分部)。原 Python 由登录技能 whoami
3738
+ // 提供;CLI 侧等价信息由会话自身承载,无需额外请求。
3739
+ const session = await env.host.getSession();
3740
+ const user = {};
3741
+ const userCandidates = [
3742
+ ['employeeId', session.userId],
3743
+ ['department', session.department],
3744
+ ['subcompany', session.subcompany],
3745
+ ['position', session.position],
3746
+ ];
3747
+ for (const [key, value] of userCandidates) {
3748
+ const text = value === undefined || value === null ? '' : String(value).trim();
3749
+ if (text)
3750
+ user[key] = text;
3751
+ }
3752
+ const oaContext = {};
3753
+ const version = session.version === undefined ? '' : String(session.version).trim();
3754
+ if (version)
3755
+ oaContext.version = version;
3756
+ if (Object.keys(user).length)
3757
+ oaContext.user = user;
3758
+ const hasOaContext = Object.keys(oaContext).length > 0;
3759
+ result.oaContext = hasOaContext ? oaContext : null;
3760
+ result.oaContextMissing = !hasOaContext;
3761
+ if (!hasOaContext) {
3762
+ result.oaContextMissingReason = '当前 E10 会话中未包含 OA 版本与用户身份信息。请先运行 weaver-work-cli auth status 确认登录态;若会话确已失效,请用 weaver-work-cli auth login --base-url <域名> --agent_type <当前智能体名称> 重新登录后重试。';
3763
+ }
3764
+ return workflowSuccess('workflow.memoryPrompt', result, {
3765
+ oaContextMissing: !hasOaContext,
3766
+ });
3767
+ },
3768
+ };
3769
+ export const workflowReadOperations = [
3770
+ workflowSearch,
3771
+ workflowTodoStat,
3772
+ workflowDetail,
3773
+ workflowSummary,
3774
+ workflowOpen,
3775
+ workflowRequestLog,
3776
+ workflowPageOpen,
3777
+ workflowMemoryPrompt,
3778
+ ];