weaver-work-cli 0.1.23 → 0.1.24

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 (41) hide show
  1. package/README.md +23 -2
  2. package/dist/internal/e10/auth/commands.js +194 -16
  3. package/dist/internal/e10/auth/xiaoe.js +15 -6
  4. package/dist/internal/e10/context.js +3 -3
  5. package/dist/internal/e10/request-runtime.js +46 -4
  6. package/dist/shortcuts/caizhilian/file-upload/metadata.js +124 -0
  7. package/dist/shortcuts/caizhilian/file-upload/multipart.js +47 -0
  8. package/dist/shortcuts/caizhilian/file-upload/protocol.js +116 -0
  9. package/dist/shortcuts/caizhilian/file-upload/receipt.js +37 -0
  10. package/dist/shortcuts/caizhilian/host.js +13 -5
  11. package/dist/shortcuts/caizhilian/operations/handlers/file-upload.js +72 -179
  12. package/dist/shortcuts/caizhilian/operations/handlers/folder.js +701 -4
  13. package/dist/shortcuts/caizhilian/operations/registry.js +5 -1
  14. package/dist/shortcuts/caizhilian/operations/specs.js +52 -0
  15. package/dist/shortcuts/workflow/continuation.js +14 -9
  16. package/dist/shortcuts/workflow/endpoints.js +11 -1
  17. package/dist/shortcuts/workflow/operations/flow-status.js +63 -0
  18. package/dist/shortcuts/workflow/operations/form.js +650 -518
  19. package/dist/shortcuts/workflow/operations/read.js +374 -13
  20. package/dist/shortcuts/workflow/operations/shared.js +3 -0
  21. package/dist/shortcuts/workflow/operations/write.js +5 -5
  22. package/dist/shortcuts/workflow/operations.js +8 -6
  23. package/docs/caizhilian.md +8 -2
  24. package/docs/e10-auth.md +18 -3
  25. package/package.json +1 -1
  26. package/skills/weaver-e10-caizhilian/SKILL.md +8 -3
  27. package/skills/weaver-e10-caizhilian/references/file-upload.md +100 -295
  28. package/skills/weaver-e10-caizhilian/references/folder.md +31 -3
  29. package/skills/weaver-e10-caizhilian/references/source-manifest.json +536 -25
  30. package/skills/weaver-e10-jiuchuanhui/SKILL.md +9 -1
  31. package/skills/weaver-e10-shared/SKILL.md +12 -2
  32. package/skills/weaver-e10-shared/references/e10-auth-and-session.md +21 -3
  33. package/skills/weaver-e10-workflow/SKILL.md +10 -8
  34. package/skills/weaver-e10-workflow/references/source-manifest.json +36 -31
  35. package/skills/weaver-e10-workflow/references/workflow-batch.md +2 -4
  36. package/skills/weaver-e10-workflow/references/workflow-create.md +58 -36
  37. package/skills/weaver-e10-workflow/references/workflow-edit.md +2 -1
  38. package/skills/weaver-e10-workflow/references/workflow-memory.md +2 -2
  39. package/skills/weaver-e10-workflow/references/workflow-operate.md +2 -1
  40. package/skills/weaver-e10-workflow/references/workflow-query.md +9 -7
  41. package/skills/weaver-e10-workflow/references/workflow-share.md +2 -0
@@ -17,11 +17,12 @@
17
17
  */
18
18
  import { randomUUID } from 'node:crypto';
19
19
  import { workflowSuccess, schemaOf, SCHEMA_REQUEST_ID, SCHEMA_CONTINUATION, SCHEMA_CONFIRM, } from './types.js';
20
- import { responseData, toArray, withLeadingMeta, stdoutBytesOf, STDOUT_SAFE_BYTES, parseNameWithId, resolveMemoryAppId, } from './shared.js';
20
+ import { asBool, responseData, toArray, withLeadingMeta, stdoutBytesOf, STDOUT_SAFE_BYTES, parseNameWithId, resolveMemoryAppId, } from './shared.js';
21
21
  import { workflowValidationError, workflowApiError, workflowPartialError, workflowPolicyError, workflowClarificationError, WorkflowCliError, } from '../errors.js';
22
22
  import { FORM_ENDPOINTS, WORKFLOW_ENDPOINTS, AJAX_QUERY, formApiUrl } from '../endpoints.js';
23
23
  import { continuationReplayError } from '../continuation.js';
24
24
  import { writeStateFileBestEffort } from '../state.js';
25
+ import { buildHistoryRequestData, filterHistoryRequestData } from './read.js';
25
26
  // ---------------------------------------------------------------------------
26
27
  // 常量(与 Python 源逐条对齐)
27
28
  // ---------------------------------------------------------------------------
@@ -47,287 +48,57 @@ const SYSTEM_FIELD_KEYS = {
47
48
  const ATTACH_SIZE_LIMIT_STEPS_MB = [1, 5, 10, 20, 50, 100];
48
49
  const CONTINUATION_TTL_MS = 10 * 60 * 1000;
49
50
  /**
50
- * `fields` 的紧凑编码(通用)。
51
- *
52
- * 字段多的表单里,`fields` 的键名要重复 N 次;pretty 输出下每个字段还会占掉 4~6 行缩进
53
- * —— 实测某 278 字段的表单 `fields` 占 35.8KB,其中键名 + 缩进比真实数据还多。这里改成
54
- * "一行一个字段、列用 `|` 分隔"的字符串数组:省掉重复键名,也只占一行缩进。
55
- *
56
- * "columns": ["id","name","subFormId","componentKey"],
57
- * "rows": ["856266610411798602|Cost Bearer||MultiContent", …],
58
- * "extra": { "<字段id>": { "required": true, "isBrowser": true, "options": [ … ] } }
59
- *
60
- * 非列信息(required / readOnly / isBrowser / options / fileUploadParams…)条目少,
61
- * 仍按字段 id 登记在 `extra`。信息不丢:完整结构写在 resultFile,这里只是换一种编码。
62
- */
63
- function encodeFieldsCompact(fields) {
64
- const columns = ['id', 'name', 'subFormId', 'componentKey'];
65
- const cell = (value) => String(value === undefined || value === null ? '' : value).replace(/[|\r\n]/g, ' ');
66
- const rows = fields.map((field) => columns.map((col) => cell(field?.[col])).join('|'));
67
- const extra = {};
68
- for (const field of fields) {
69
- if (!field || typeof field !== 'object')
70
- continue;
71
- const rest = {};
72
- for (const [key, value] of Object.entries(field)) {
73
- if (!columns.includes(key))
74
- rest[key] = value;
75
- }
76
- if (Object.keys(rest).length)
77
- extra[String(field.id ?? '')] = rest;
78
- }
79
- return { columns, rows, rowCount: rows.length, extra };
80
- }
81
- /**
82
- * `fields` 被整体移出 stdout 时的补偿索引(V12.1.34)。
51
+ * 大表单输出保护(通用,不针对任何具体工作流/表单)。
83
52
  *
84
- * 极端档(`fieldsOmitted`)此前只留 `requiredFields` / `subFormRequired` / `editableFields`
85
- * 三个**纯 id 数组**,模型拿不到「字段名 ↔ 字段 id」的对应关系,只能自己写脚本去 resultFile 里挖
86
- * (实测某次会话为此多写 3 个脚本、3 次被 head 截断,白花 6 次工具往返)。
87
- * 这里补一份**只含可写字段**的紧凑索引(行 = `id|name|subFormId|componentKey|flags`),
88
- * 体积很小(某 278 字段表单实测 3.7KB,其余字段 17.5KB,合计 21.2KB < 安全线),
89
- * 足以让模型直接构造 `data` 的键。
53
+ * 字段多的表单,`createForm` / `editLoad` 的输出能轻松超过 60KB,会被宿主截断成 2KB 预览;
54
+ * 模型于是自己写脚本、分段读取,白花好几轮工具往返。这里对齐 `todoStat` 的做法:超安全线时把
55
+ * **完整结果**写入 resultFile,stdout **只回一条提示** —— 落盘路径 + 一条可照抄执行的读文件命令。
90
56
  *
91
- * V12.1.37(2026-09-23):把两类**填值前就要核对、又恰好不在行内**的属性随索引下发 ——
92
- * 它们此前只能去 resultFile 取,实测就是那次「二次读取」的主要动因:
93
- * - `formatIndex`(字段 id → `format`):日期/时间字段的格式(`yyyy-MM-dd` 等)。只有 `T` 标记时
94
- * 模型知道"有格式要求"却不知道要求什么,只能读 resultFile;实测某会话为它多跑一轮 ≈11 s。
95
- * - `uploadLimits`(字段 id → `大小上限|格式限制|数量上限`):附件字段的上传限制,见 `uploadLimitOf`。
57
+ * 2026-09-29 用户裁定:此前超线时还会回「摘要 + 紧凑编码 `fields` + `writableFields` 补偿索引」,
58
+ * 实测反而把模型带进"先看看手里有什么、再决定读不读文件"的分叉:某次真机会话里落的是**只剩指针**
59
+ * 那一档(`fields` / `writableFields` / `editableFields` 全被移出 stdout),模型于是先 grep 找
60
+ * `writableFields`、再读 `requiredFields`、发现 `fields` 是完整对象后又按 17 个字段 id 批量 grep,
61
+ * **从头到尾没读那个文件**。现在超线只有一条路:照抄命令读文件。
96
62
  *
97
- * 仍需按字段 id 去 resultFile 取的只剩 `browserModule` / `browserType` / 完整 `options` 结构等
98
- * 低频元数据。纯结构裁剪,不做任何语义判断。
63
+ * **返回体里不再有任何表单属性** —— 字段清单、必填、历史参考值、`contextId` / `continuation`
64
+ * 全部只在 resultFile 里,所以提示必须把"不读就没法继续"说透。
99
65
  *
100
- * @param writableIds 可写字段 id(含系统字段),顺序即输出顺序的同构子集
101
- * @param sourceFields 字段来源。**必须传已挂上 `fileUploadParams` / `folderDependsOnOption` 的那份**
102
- * (createForm 传 `slimFields`;传早期的 `visibleFields` 会让 F/D 两个标记恒不出现)
103
- * @param systemNames 系统字段 id → 中文名(不在 sourceFields 里的兜底)
66
+ * 落盘不可用时没有文件可指,只能保持全量输出(宁大不丢数据),只补一句说明。
104
67
  */
105
- function encodeWritableFieldsCompact(writableIds, sourceFields, systemNames) {
106
- const byId = new Map();
107
- for (const f of sourceFields)
108
- byId.set(String(f?.id ?? ''), f);
109
- const cell = (value) => String(value === undefined || value === null ? '' : value).replace(/[|\r\n]/g, ' ');
110
- const flagsOf = (f) => {
111
- const component = String(f.componentKey ?? '').toLowerCase();
112
- let flags = '';
113
- if (f.required)
114
- flags += 'R';
115
- if (f.isBrowser)
116
- flags += 'B';
117
- if (f.isSelect)
118
- flags += 'S';
119
- if (f.isMultiple)
120
- flags += 'M';
121
- if (component === 'employeescope')
122
- flags += 'E';
123
- if (component === 'einvoicecomponent')
124
- flags += 'V';
125
- if (f.folderDependsOnOption)
126
- flags += 'D';
127
- if (f.fileUploadParams)
128
- flags += 'F';
129
- if (Array.isArray(f.options) && f.options.length)
130
- flags += 'O';
131
- if (String(f.format ?? '').trim())
132
- flags += 'T';
133
- return flags;
134
- };
135
- const rows = [];
136
- const optionIndex = {};
137
- const formatIndex = {};
138
- const uploadLimits = {};
139
- const emitted = new Set();
140
- const push = (id) => {
141
- if (emitted.has(id))
142
- return;
143
- emitted.add(id);
144
- const f = byId.get(id);
145
- if (!f) {
146
- rows.push([id, systemNames[id] || '', '', '', ''].map(cell).join('|'));
147
- return;
148
- }
149
- rows.push([id, f.name, f.subFormId, f.componentKey, flagsOf(f)].map(cell).join('|'));
150
- const options = Array.isArray(f.options) ? f.options : [];
151
- if (options.length) {
152
- optionIndex[id] = options.slice(0, 30).map((o) => (o && typeof o === 'object' ? String(o.name ?? o.content ?? '') : String(o)));
153
- }
154
- const format = String(f.format ?? '').trim();
155
- if (format)
156
- formatIndex[id] = format;
157
- const limits = uploadLimitOf(f);
158
- if (limits)
159
- uploadLimits[id] = limits;
160
- };
161
- const wanted = new Set(writableIds);
162
- for (const f of sourceFields) {
163
- const id = String(f?.id ?? '');
164
- if (wanted.has(id))
165
- push(id);
166
- }
167
- for (const id of writableIds)
168
- push(id);
169
- const out = {
170
- columns: ['id', 'name', 'subFormId', 'componentKey', 'flags'],
171
- rows,
172
- rowCount: rows.length,
173
- flagLegend: 'R=必填 B=浏览按钮 S=选项 M=多选 E=人员范围 V=发票 D=附件目录随选项 '
174
- + 'F=有上传参数(见 uploadLimits) O=有选项(见 optionIndex) '
175
- + 'T=有日期/时间格式要求(见 formatIndex)',
176
- };
177
- if (Object.keys(optionIndex).length)
178
- out.optionIndex = optionIndex;
179
- if (Object.keys(formatIndex).length)
180
- out.formatIndex = formatIndex;
181
- if (Object.keys(uploadLimits).length)
182
- out.uploadLimits = uploadLimits;
183
- return out;
184
- }
68
+ /** 落盘不可用时的叮嘱(此时没有文件可读,只能就地把输出读完)。 */
69
+ const READ_WHOLE_OUTPUT_HINT = '读取本输出请一次读完:不要用 head/tail 等管道截断'
70
+ + '(截断会破坏 JSON,并导致重复执行同一条命令)。';
185
71
  /**
186
- * 附件字段的上传限制 → 一行紧凑描述(`大小上限|格式限制|数量上限`)。
72
+ * 超线时的**唯一**返回内容:落盘路径 + 一条可直接照抄的读文件命令(措辞由用户 2026-09-29 指定)。
187
73
  *
188
- * 只摘模型填值前要核对的三项,**丢掉服务器侧管道**:`customParam`(`authStr`/`authSignatureStr`/
189
- * `requestId`/`nodeId`/`identityId`…)、`folderId`/`refId`/`sourceRefId`/`source`/`module` 等,
190
- * 单字段原块 ≈700 B 而其中三项限制合计不到 120 B。
74
+ * 用户明确要求:① 超线**只回提示**,不再回任何精简属性 —— 否则模型会先思考一段再决定读不读;
75
+ * ② 提示里**直接给出可执行的读命令**(工具 + `file_path` + 精确 `limit`),避免模型自己把 `limit`
76
+ * 给小了、于是分段读好几次;③ **除这两行外一个字都不要**(原话:"其余内容都去掉")。
191
77
  *
192
- * - `sizeLimit` 单位 MB;`formatLimit.mode='white'` 记「白名单」、其余记「黑名单」;
193
- * `formats=['*.*']`(或 `['*']`)记「不限格式」。
194
- * - 缺项一律写 `-`,**不要留空**:留空会被读成"限制为 0/不允许"。
195
- * - 无 `fileUploadParams` 的字段返回空串(调用方不产出该条目)。
78
+ * `lines` 是落盘文件的总行数,正好是一次读完所需的 `limit`。文件读取工具不受宿主 stdout 那条
79
+ * ~30 KB 内联线约束(实测 76 KB / 238 KB 都能一次读回)。
196
80
  */
197
- function uploadLimitOf(f) {
198
- const params = (f && typeof f === 'object') ? f.fileUploadParams : null;
199
- if (!params || typeof params !== 'object')
200
- return '';
201
- const size = Number(params.sizeLimit);
202
- const maxNum = Number(params.maxNum);
203
- const limit = (params.formatLimit && typeof params.formatLimit === 'object') ? params.formatLimit : null;
204
- const formats = Array.isArray(limit?.formats)
205
- ? limit.formats.map((v) => String(v).trim()).filter(Boolean)
206
- : [];
207
- const formatText = !formats.length
208
- ? '-'
209
- : (formats.length === 1 && (formats[0] === '*.*' || formats[0] === '*'))
210
- ? '不限格式'
211
- : `${String(limit?.mode ?? '').toLowerCase() === 'white' ? '白名单' : '黑名单'}:${formats.join(',')}`;
212
- return [
213
- Number.isFinite(size) && size > 0 ? `${size}MB` : '-',
214
- formatText,
215
- Number.isFinite(maxNum) && maxNum > 0 ? `${maxNum}个` : '-',
216
- ].join('|');
81
+ function resultFileNotice(resultFile, lines) {
82
+ return `表单内容较大,已写入临时文件(resultFile:${resultFile}),无需思考,直接运行下面的命令读取完整表单内容:`
83
+ + `\nRead(file_path: "${resultFile}", limit: ${lines})`;
217
84
  }
218
- /**
219
- * 大表单输出保护(通用,不针对任何具体工作流/表单)。
220
- *
221
- * 字段多的表单,`createForm` / `editLoad` 的输出能轻松超过 60KB,会被宿主截断成 2KB 预览;
222
- * 模型于是写脚本读回、读回又被截断,白花好几轮工具往返(实测某次会话为此多花 5 次)。这里
223
- * 对齐 `todoStat` 的做法:超安全线时把**完整结果**写入 resultFile,stdout 只回精简版并前置
224
- * `resultFile`(放最前,被截断也看得见),同时把 `fields` 换成列式编码。
225
- *
226
- * 落盘不可用时保持全量输出(宁大不丢数据),只补一句说明。
227
- *
228
- * V12.1.34:入口可传 `fieldsOmittedCompensation`(可写字段紧凑索引)。它**只在 `fields` 真的被
229
- * 整体移出 stdout 时**才随摘要一起产出 —— 紧凑编码那一档 `fields` 仍在,模型本来就能读到字段名,
230
- * 不需要重复体积;小表单(未超线)完全不产出,避免无谓地把输出顶到安全线上。
231
- */
232
- /** 读取本输出的统一叮嘱(紧凑版与兜底版共用)。 */
233
- const READ_WHOLE_OUTPUT_HINT = '读取本输出请一次读完:不要用 head/tail 等管道截断'
234
- + '(截断会破坏 JSON,并导致重复执行同一条命令);本输出被截断读不全时直接读 resultFile,不要重跑本命令。';
235
- async function compactFormOutput(result, env, resultFileName, fieldsOmittedCompensation) {
85
+ async function compactFormOutput(result, env, resultFileName) {
236
86
  const bytes = stdoutBytesOf(result);
87
+ // 未超本地安全线 ⇒ 原样返回,不做任何裁剪、也不落盘。
237
88
  if (bytes <= STDOUT_SAFE_BYTES)
238
89
  return result;
239
- const fields = toArray(result.fields);
240
- const resultFile = await writeStateFileBestEffort(resultFileName, JSON.stringify(result, null, 2), env.ctx);
90
+ const payload = JSON.stringify(result, null, 2);
91
+ const resultFile = await writeStateFileBestEffort(resultFileName, payload, env.ctx);
241
92
  if (!resultFile) {
242
- return {
243
- ...result,
244
- outputBytes: bytes,
245
- largeOutputNotice: `表单字段较多(${fields.length} 个)且本地落盘不可用,已按原样返回全量 fields。`
246
- + READ_WHOLE_OUTPUT_HINT,
247
- };
93
+ return { ...result, outputBytes: bytes, largeOutputNotice: READ_WHOLE_OUTPUT_HINT };
248
94
  }
249
- const compact = withLeadingMeta({
250
- resultFile,
251
- persistedResult: true,
252
- outputBytes: bytes,
253
- largeOutputNotice: `表单字段较多(${fields.length} 个),完整结果已写入 resultFile。`
254
- + 'stdout 里的 fields 是紧凑编码:columns 为列名,rows 每行一个字段、各列用 "|" 分隔'
255
- + '(顺序与 columns 一致),extra 按字段 id 存放非列信息'
256
- + '(required / readOnly / disableEdit / isBrowser / isSelect / isMultiple / browserModule /'
257
- + ' browserType / options / fileUploadParams 等;未出现在 extra 里的字段即为默认值)。'
258
- + '需要某字段的完整原始元数据时,从 resultFile 里按该字段 id 取。'
259
- + 'resultFile 是完整 pretty JSON、体积很大,请用脚本按需提取,不要整份读入上下文。'
260
- + READ_WHOLE_OUTPUT_HINT,
261
- }, {
262
- ...result,
263
- fields: encodeFieldsCompact(fields),
264
- });
265
- if (stdoutBytesOf(compact) <= STDOUT_SAFE_BYTES)
266
- return compact;
267
- // 极端兜底(字段极多的表单,紧凑编码也压不进安全线):stdout 只留指针 + 摘要,
268
- // 字段清单去 resultFile 按需提取 —— 否则整段输出会被宿主截成 2KB 预览,等于什么都没拿到。
269
- // V12.1.34:随摘要附上**可写字段补偿索引**,模型不必再自己写脚本挖 resultFile。
270
- // V12.1.35(实测 278 字段表单:美化 37,887 B → ≈17 KB,落进宿主 28 KB 内联区):
271
- // ① 安全线改用**美化口径** `stdoutBytesOf()`(与实际 stdout 一致)。此前量压缩 JSON,**低估 1.79 倍**
272
- // ⇒ 这一档从未触发,且 `writableFields` 被追加在末尾(起始偏移 31,093),宿主只给 2048 B 预览
273
- // ⇒ 模型永远看不到它,只能自写脚本挖 resultFile(实测白烧 4 轮工具往返、约 54 s)。
274
- // ② `editableFields`(与 writableFields 冗余)移出 stdout,只留可写索引 — 二者合起来才够把体积压进宿主内联区。
275
- // ③ `writableFields` 前置到载荷最前,任何截断场景下都先被看到。
276
- const summary = { ...result };
277
- delete summary.fields;
278
- delete summary.editableFields;
279
- /**
280
- * "字段清单去哪了"的文案——`withIndex` 表示**本档是否真的带了** `writableFields`。
281
- *
282
- * 2026-09-22 修正:只留指针那一档此前复用同一段文案,于是在**没给索引的档位里声称"已给出索引"**,
283
- * 既与同档的 `writableFieldsOmitted: true` 标记互相矛盾,又把模型指向一个不存在的键。
284
- *
285
- * 2026-09-23 修正:文案此前只提 `optionIndex`,还把 `fileUploadParams` 列进"要读 resultFile"的清单 ——
286
- * 而 `formatIndex` / `uploadLimits` 其实**已经随索引下发**,本可覆盖"日期格式""上传限制"这两个最常见的
287
- * 回读动因。文案不提它们,等于默许模型为这两项去读 resultFile(实测就是这么白跑一轮)。现按实际下发的
288
- * 三类索引(`optionIndex` / `formatIndex` / `uploadLimits`,均按需出现)改写,并删掉 `fileUploadParams`。
289
- */
290
- const fieldsOmittedNoticeOf = (withIndex) => `表单字段过多(${fields.length} 个),字段清单已整体移出 stdout,`
291
- + '完整结果(含全部 fields 原始元数据)已写入 resultFile。'
292
- + (withIndex
293
- ? 'stdout **最前面**已给出全部可写字段的紧凑索引 writableFields:columns 为列名、rows 每行一个字段'
294
- + '(`id|name|subFormId|componentKey|flags`,flags 含义见 flagLegend)。填值前要核对的映射'
295
- + '也随同一索引下发:选项名见 optionIndex、日期/时间格式见 formatIndex、附件上传限制见 uploadLimits'
296
- + '(三者按需出现,没有该类字段时没有该键)。**直接用 writableFields 里的字段 id 构造 data 的键**,'
297
- + '选项、日期格式、上传限制一律照上述索引填 —— 不要为了拿字段清单、选项、格式或上传限制再写脚本解析 resultFile;'
298
- + '只有需要这里没有的字段元数据(browserModule / browserType / 完整 options 结构)时,'
299
- + '才按该字段 id 从 resultFile 取。'
300
- : '本档体积太紧,**可写字段索引也放不进 stdout**——构造表单数据前,请按字段 id 从 resultFile 里提取所需键,'
301
- + '按需只取几个键、不要把整份 resultFile 读入上下文。');
302
- const metaOf = (withIndex = true) => ({
95
+ // 只回提示:路径说明 + 读命令(`resultFile` 排最前,被截断也看得见)。
96
+ return withLeadingMeta({
303
97
  resultFile,
304
98
  persistedResult: true,
305
99
  outputBytes: bytes,
306
- fieldsOmitted: true,
307
- fieldCount: fields.length,
308
- largeOutputNotice: fieldsOmittedNoticeOf(withIndex) + READ_WHOLE_OUTPUT_HINT,
309
- });
310
- const orderedBody = (includeWritable = true) => {
311
- const out = {};
312
- if (includeWritable && fieldsOmittedCompensation)
313
- out.writableFields = fieldsOmittedCompensation;
314
- for (const [key, value] of Object.entries(summary)) {
315
- if (value !== undefined)
316
- out[key] = value;
317
- }
318
- return out;
319
- };
320
- // 第一档:可写字段索引 + 摘要(正常的大表单都落在这里)
321
- const withIndex = withLeadingMeta(metaOf(true), orderedBody(true));
322
- if (stdoutBytesOf(withIndex) <= STDOUT_SAFE_BYTES)
323
- return withIndex;
324
- // 第二档(字段数量极端):索引也放不进安全线,退回只留指针,并显式标注索引被省略了。
325
- // 2026-09-22 修正:这一档**必须真的把 writableFields 去掉**——此前仍经 `orderedBody(true)` 把它塞回去,
326
- // 结果既没压进安全线,又与 `writableFieldsOmitted: true` 自相矛盾、文案还在说"已给出索引"。
327
- return withLeadingMeta({
328
- ...metaOf(false),
329
- ...(fieldsOmittedCompensation ? { writableFieldsOmitted: true } : {}),
330
- }, orderedBody(false));
100
+ largeOutputNotice: resultFileNotice(resultFile, payload.split('\n').length),
101
+ }, {});
331
102
  }
332
103
  async function requestJson(env, method, path, query, body, options = {}) {
333
104
  const response = await env.host.workflowRequest({
@@ -591,6 +362,50 @@ function buildLayoutRequest(workflowId, runtime, frame) {
591
362
  // ---------------------------------------------------------------------------
592
363
  // 字段提取(extractFields)
593
364
  // ---------------------------------------------------------------------------
365
+ /**
366
+ * 从 `title` 这类"可能是字符串、也可能是对象"的值里取出展示文本。
367
+ *
368
+ * 真机(2026-09-29「会议申请」):布局里 `title` **不保证是字符串** —— `NewButton` 组件给的是
369
+ * `{nameAlias: "会议室使用情况"}`。旧实现直接 `String(raw.title)`,于是 `fields[].name` 出现了
370
+ * `"[object Object]"`:模型拿着这个名字既认不出字段,也没法向用户复述。
371
+ */
372
+ const TITLE_TEXT_KEYS = ['nameAlias', 'zh_CN', 'zhCN', 'name_zh', 'text', 'label', 'value', 'content', 'name', 'title'];
373
+ function textOfTitleValue(value, depth = 0) {
374
+ if (typeof value === 'string')
375
+ return value;
376
+ if (typeof value === 'number' || typeof value === 'boolean')
377
+ return String(value);
378
+ if (!value || typeof value !== 'object' || depth >= 3)
379
+ return '';
380
+ for (const key of TITLE_TEXT_KEYS) {
381
+ const inner = value[key];
382
+ if (inner === undefined || inner === null)
383
+ continue;
384
+ const text = textOfTitleValue(inner, depth + 1);
385
+ if (text.trim())
386
+ return text;
387
+ }
388
+ return '';
389
+ }
390
+ /**
391
+ * 字段展示名:`title` 优先(对象先取文本),再回落 `name` / `dataKey` / 字段 id。
392
+ *
393
+ * 同时清掉 `[object Object]` —— 服务端自己拼 `MultiContent` 容器标题时就有 Java 拼接 bug
394
+ * (真机实例 `"会议地点;[object Object]"`,`title` 本身就是这么个字符串),残留段去掉后
395
+ * 再收掉空的分段,免得把服务端的缺陷当成字段名传给模型。
396
+ */
397
+ function fieldTitleOf(raw, fieldId) {
398
+ const text = (textOfTitleValue(raw.title, 0)
399
+ || textOfTitleValue(raw.name, 0)
400
+ || textOfTitleValue(raw.dataKey, 0))
401
+ .replace(/\[object Object\]/gu, '')
402
+ .split(';')
403
+ .map((part) => part.trim())
404
+ .filter(Boolean)
405
+ .join(';')
406
+ .trim();
407
+ return text || fieldId;
408
+ }
594
409
  function extractFields(layoutData, requiredMap, disableEditMap, displayMap) {
595
410
  const candidates = {};
596
411
  const walk = (value) => {
@@ -638,7 +453,7 @@ function extractFields(layoutData, requiredMap, disableEditMap, displayMap) {
638
453
  const isSelect = SELECT_COMPONENTS.has(normalized) || options.length > 0;
639
454
  result.push({
640
455
  id: fieldId,
641
- name: String(raw.title ?? raw.name ?? raw.dataKey ?? fieldId),
456
+ name: fieldTitleOf(raw, fieldId),
642
457
  componentKey: component,
643
458
  required: (requiredMap[fieldId] !== undefined) ? requiredMap[fieldId] : Boolean(raw.required || raw.mustInput),
644
459
  disableEdit: (disableEditMap[fieldId] !== undefined) ? disableEditMap[fieldId] : Boolean(raw.disableEdit),
@@ -1222,7 +1037,7 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1222
1037
  // 澄清文案里回显的是**用户原话**(不是切分后的关键词)——用户要能认出自己给的条件
1223
1038
  const queryText = rawTerms.join('、');
1224
1039
  if (!rawTerms.length) {
1225
- throw workflowClarificationError('workflow', '', '要发起哪个流程?请提供可发起工作流的名称、`名称(ID)` 或工作流 ID 后再继续。');
1040
+ throw workflowClarificationError('workflow', '', '要发起哪个流程?请提供该流程的完整名称(或其它可用于检索的关键词)后再继续。');
1226
1041
  }
1227
1042
  // 先做**纯本地**的解析:显式 ID(`名称(ID)` / 纯数字)与名称关键词(一格可含多个,见
1228
1043
  // `splitCreateKeywords`)。这一步不依赖远端结果,于是能先判"到底有没有可用条件"——
@@ -1248,7 +1063,7 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1248
1063
  }
1249
1064
  }
1250
1065
  if (!explicitIds.size && !nameTerms.length) {
1251
- throw workflowClarificationError('workflow', queryText, '要发起哪个流程?请提供可发起工作流的名称、`名称(ID)` 或工作流 ID 后再继续。');
1066
+ throw workflowClarificationError('workflow', queryText, '要发起哪个流程?请提供该流程的完整名称(或其它可用于检索的关键词)后再继续。');
1252
1067
  }
1253
1068
  const response = await requestJson(env, 'POST', FORM_ENDPOINTS.workflowCreateList, AJAX_QUERY, {
1254
1069
  wfpBelongTypeCode: '1', loadOfsFlow: '0', clientCtrl: 1,
@@ -1302,7 +1117,7 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1302
1117
  })));
1303
1118
  }
1304
1119
  throw workflowClarificationError('workflow', queryText, `「${queryText}」在当前账号下没有匹配到可发起的工作流(也可能已停用或无发起权限)。`
1305
- + '请核对名称,或给出准确的完整名称 / 工作流 ID 后再继续。');
1120
+ + '请核对名称,或给出该流程更完整的名称(或其它可用于检索的关键词)后再继续。');
1306
1121
  }
1307
1122
  /**
1308
1123
  * 从「全局 + 该工作流维度」的合并记忆里**只取工作流维度那一段**。
@@ -1433,7 +1248,7 @@ function readCreateFormBlock(base) {
1433
1248
  */
1434
1249
  function createFormBlockedError(workflowId, block) {
1435
1250
  if (block.reason === 'workflow_not_exist') {
1436
- return workflowClarificationError('workflow', workflowId, `工作流 ID「${workflowId}」不存在(可能已删除或被停用)。请核对 ID,或直接给出流程名称重新指定。`, [], { clarificationReason: 'workflow_not_exist' });
1251
+ return workflowClarificationError('workflow', workflowId, `工作流「${workflowId}」不存在(可能已删除或被停用)。请改用该流程的完整名称(或其它可用于检索的关键词)重新指定。`, [], { clarificationReason: 'workflow_not_exist' });
1437
1252
  }
1438
1253
  return workflowClarificationError('workflow', workflowId, `工作流 ID「${workflowId}」**存在**,但当前账号没有发起权限`
1439
1254
  + `${block.detail ? `(${block.detail})` : ''}。`
@@ -1458,12 +1273,12 @@ function singleExplicitWorkflowId(rawWorkflow) {
1458
1273
  }
1459
1274
  /** 表单载入 + 该工作流的发起偏好:两者只共享 `workflowId`,可以并行。 */
1460
1275
  async function loadCreateFormPack(workflowId, env) {
1461
- const [{ data, writableFieldsCompact }, launchPreferences] = await Promise.all([
1276
+ const [{ data }, launchPreferences] = await Promise.all([
1462
1277
  buildCreateFormData(workflowId, env),
1463
1278
  // 记忆读取是 best-effort(内部全 catch),不会把表单载入的错误顶掉。
1464
1279
  fetchLaunchPreferences(workflowId, env),
1465
1280
  ]);
1466
- return { data, writableFieldsCompact, launchPreferences };
1281
+ return { data, launchPreferences };
1467
1282
  }
1468
1283
  async function commandCreateForm(args, env) {
1469
1284
  // 会话是**惰性**加载的(`ensureSession` 只在首次调用时填充)。本命令随后会并发发起多个请求,
@@ -1488,7 +1303,7 @@ async function commandCreateForm(args, env) {
1488
1303
  ...pack.data,
1489
1304
  workflow: { ...(pack.data.workflow || {}), selectionSource: target.source },
1490
1305
  launchPreferences: pack.launchPreferences,
1491
- }, env, 'last_create_form.json', pack.writableFieldsCompact));
1306
+ }, env, 'last_create_form.json'));
1492
1307
  }
1493
1308
  // ---------------------------------------------------------------------------
1494
1309
  // createForm:loadBase/loadFrame/loadMinor/formLayout + 字段输出 + fileUploadParams
@@ -1689,14 +1504,16 @@ async function buildCreateFormData(workflowId, env) {
1689
1504
  }
1690
1505
  const slimFields = [];
1691
1506
  for (const field of visibleFields) {
1692
- const item = {
1693
- id: field.id,
1694
- name: field.name,
1695
- subFormId: field.subFormId,
1696
- componentKey: field.componentKey,
1697
- };
1698
- if (field.required)
1699
- item.required = true;
1507
+ // 键序 id|name|subFormId|componentKey 是契约;`subFormId` **为空就整个省略**(缺该键=主表字段)
1508
+ // —— 主表字段占绝大多数,写 `""` 恒白占一条键(真机 278 字段表单:100 个主表字段 × 22 B ≈ 2.2 KB)。
1509
+ const slimSubFormId = String(field.subFormId ?? '');
1510
+ const item = { id: field.id, name: field.name };
1511
+ if (slimSubFormId)
1512
+ item.subFormId = slimSubFormId;
1513
+ item.componentKey = field.componentKey;
1514
+ // `fields` **不返回** `required`:必填只从 `requiredFields`(主表)/ `subFormRequired`(明细)取。
1515
+ // `fields` 是布局级字段全集(含联动隐藏、未在当前页展示的字段),在这里标必填会让模型
1516
+ // 拿它去推演"能填什么 / 哪些是必填",与真实可填集合(`editableFields`)混淆。
1700
1517
  if (field.readOnly || field.disableEdit) {
1701
1518
  item.readOnly = Boolean(field.readOnly);
1702
1519
  item.disableEdit = Boolean(field.disableEdit);
@@ -1763,6 +1580,30 @@ async function buildCreateFormData(workflowId, env) {
1763
1580
  iv.subFormId = sf;
1764
1581
  initValues.push(iv);
1765
1582
  }
1583
+ /**
1584
+ * 必填字段 → 历史可参考值(V12.1.77:取数点从 `createRequest` **前移到这里**)。
1585
+ *
1586
+ * 为什么前移到 createForm:
1587
+ * - 模型现在能在**第一次** `createRequest` 之前就拿到"这张表单的必填项,历史单里通常填什么",
1588
+ * 于是很多必填项不必再问用户(等 `createRequest` 才发现缺失时,已经多花了一轮往返)。
1589
+ * - `createRequest` 侧退化成"只从缓存里筛",**不再联网取数**——缺失字段反复出现时也不会重复查。
1590
+ *
1591
+ * 缓存口径(`allFormFields`):保留**本表单**所有能取到值的字段,而不只是当前的必填字段。
1592
+ * 原因:条件必填由联动实时算出,`createForm` 阶段根本不知道哪些字段最终会变成必填;只有缓存
1593
+ * 放宽到"本表单全部字段",`createRequest` 才能对**新出现的**必填字段给出历史值。
1594
+ * 范围靠"本表单字段 id"收口(`fieldMeta` 只含 `fields` 里的字段),不会把别的表单的字段带进来;
1595
+ * 另把查询条件钉在**本次要发起的工作流**上(`workflowId`)——不同表单的字段 id 不通用,
1596
+ * 不收窄的话"最近 5 条我发起的"很可能一条都不是本表单 ⇒ 白查两次接口还拿不到值。
1597
+ *
1598
+ * 只在表单真的有字段时取数;失败一律降级为 `null`,绝不拖垮 `createForm`(见 read.ts 取数注释)。
1599
+ */
1600
+ const historyRequiredFieldIds = [...new Set([
1601
+ ...requiredFieldsList.map((f) => String(f?.id ?? '')),
1602
+ ...Object.values(subFormRequired).flatMap((list) => list.map((f) => String(f?.id ?? ''))),
1603
+ ].filter(Boolean))];
1604
+ const historyCache = fields.length
1605
+ ? await buildHistoryRequestData(env, fields, historyRequiredFieldIds.map((id) => ({ id, name: String((fieldsById.get(id) || {}).name ?? id) })), { allFormFields: true, workflowId })
1606
+ : null;
1766
1607
  const session = await getSessionContext(env);
1767
1608
  const context = {
1768
1609
  kind: 'CREATE',
@@ -1804,23 +1645,13 @@ async function buildCreateFormData(workflowId, env) {
1804
1645
  minorInfo,
1805
1646
  tenantSetting,
1806
1647
  onLoadLinkage,
1648
+ // 历史取数的**完整**结果(本表单全部字段)与本次判定出的必填字段集合:随句柄带去
1649
+ // `createRequest`,让它对新出现的必填字段直接筛选,不必再联网(见下方取数注释)。
1650
+ historyCache,
1651
+ historyRequiredFieldIds,
1807
1652
  };
1808
1653
  const token = await env.continuation.issue({ operation: 'workflow.createForm', baseUrl: session.baseUrl, profile: session.profile, userId: session.userId, payload: context }, CONTINUATION_TTL_MS);
1809
1654
  const requiredFieldIds = requiredFieldsWithSystem(requiredFieldsList, runtime, minorInfo.permissions || {});
1810
- // 可写字段 = 必填(主表 + 明细,含系统字段)+ 其余可编辑字段。
1811
- // 只在 `fields` 真被整体移出 stdout 时才会随输出(由 compactFormOutput 决定)。
1812
- const writableFieldIds = [];
1813
- for (const id of requiredFieldIds)
1814
- writableFieldIds.push(id);
1815
- for (const list of Object.values(subFormRequired)) {
1816
- for (const f of list)
1817
- writableFieldIds.push(String(f?.id ?? ''));
1818
- }
1819
- for (const f of editableFieldsList)
1820
- writableFieldIds.push(String(f?.id ?? ''));
1821
- // 字段来源必须是 slimFields(它才带 fileUploadParams / folderDependsOnOption):传 visibleFields
1822
- // 会让 F/D 两个标记恒不出现,uploadLimits 也整块缺失。
1823
- const writableFieldsCompact = encodeWritableFieldsCompact([...new Set(writableFieldIds)], slimFields, SYSTEM_FIELD_KEYS);
1824
1655
  return {
1825
1656
  data: {
1826
1657
  status: 'FORM_READY',
@@ -1834,9 +1665,17 @@ async function buildCreateFormData(workflowId, env) {
1834
1665
  form: { id: frame.form_id, layout_id: layoutId },
1835
1666
  requiredFields: requiredFieldIds,
1836
1667
  subFormRequired: Object.fromEntries(Object.entries(subFormRequired).map(([k, v]) => [k, v.map((f) => String(f.id ?? ''))])),
1837
- editableFields: editableFieldsList.map((f) => String(f.id ?? '')),
1668
+ // `editableFields` = 「非必填的可写字段」∪「`requiredFields`」——必填字段同样是可写字段,
1669
+ // 模型按本清单拼 `data` 时不必再去 `requiredFields` 里二次查一遍(明细必填见 `subFormRequired`)。
1670
+ editableFields: [...new Set([
1671
+ ...editableFieldsList.map((f) => String(f.id ?? '')),
1672
+ ...requiredFieldIds.map((id) => String(id ?? '')),
1673
+ ].filter(Boolean))],
1838
1674
  fields: slimFields,
1839
1675
  initialValues: initValues,
1676
+ // 本表单**必填字段**的历史参考值(近一年、非草稿、最近 5 条「我发起的」)——填值前先看它,
1677
+ // 能直接照用的字段就别去问用户。完整结果(含非必填字段)已随句柄留给 `createRequest` 用。
1678
+ historyRequestData: filterHistoryRequestData(historyCache, historyRequiredFieldIds),
1840
1679
  systemFields: {
1841
1680
  permissions: minorInfo.permissions || {},
1842
1681
  defaults: context.systemFields.defaults,
@@ -1848,7 +1687,6 @@ async function buildCreateFormData(workflowId, env) {
1848
1687
  // 原 Python 把完整 context 落盘;此处通过 continuation 承载,返回结构保持文档契约。
1849
1688
  continuation: token,
1850
1689
  },
1851
- writableFieldsCompact,
1852
1690
  };
1853
1691
  }
1854
1692
  // ---------------------------------------------------------------------------
@@ -2055,6 +1893,106 @@ function assertMultiplicity(field, options) {
2055
1893
  throw new ValueAmbiguity({ kind: 'MULTIPLICITY', fieldId: field.id, fieldName: field.name, reason: '该字段是单选,但输入了多个值', candidates: options });
2056
1894
  }
2057
1895
  }
1896
+ /**
1897
+ * 一个字段格子里的「多种说法」拆分(与可发起工作流名称同口径,见 `splitCreateKeywords`):
1898
+ * 半角/全角逗号分隔 → trim → 丢空 → 去重(保序)。
1899
+ *
1900
+ * 与 `splitCreateKeywords` 的唯一差别:**不做工作流填充词脱壳**——浏览控件取值的名称里
1901
+ * 合法地可能出现「流程」二字(如「流程部会议室」),照搬那套会把名称削坏。
1902
+ */
1903
+ function splitValueAliases(raw) {
1904
+ const out = [];
1905
+ const seen = new Set();
1906
+ for (const piece of String(raw ?? '').split(/[,,]/u)) {
1907
+ const token = piece.trim();
1908
+ if (!token || seen.has(token))
1909
+ continue;
1910
+ seen.add(token);
1911
+ out.push(token);
1912
+ }
1913
+ return out;
1914
+ }
1915
+ /** 浏览按钮字段解析不出来的统一出口。`candidates` **只含关键词真实命中**,绝不用首页枚举冒充。 */
1916
+ function browserAmbiguity(field, input, hits) {
1917
+ return new ValueAmbiguity({
1918
+ kind: 'BROWSER',
1919
+ fieldId: field.id,
1920
+ fieldName: field.name,
1921
+ input,
1922
+ reason: hits.length ? 'MULTIPLE_MATCHES' : 'NOT_FOUND',
1923
+ candidates: hits,
1924
+ candidatesAvailable: hits.length > 0,
1925
+ resolution: hits.length
1926
+ ? '把 candidates 原样交给用户确认;用户选定后由你取该项的 id / name 拼出 {"id":…,"name":…} 重发同一条命令(不要让用户提供 ID)。'
1927
+ : '该值在系统里没有匹配。请让用户给出该对象的完整名称,或其它可用于检索的关键词(不要向用户索取 ID)。',
1928
+ });
1929
+ }
1930
+ /**
1931
+ * 浏览按钮字段「一个字符串 → 一个 {id,name}」的解析。
1932
+ *
1933
+ * 一个格子可写多个**说法**(逗号分隔,与可发起工作流名称同口径),也可写 `名称(id)` 直接给 ID。
1934
+ *
1935
+ * **候选池只取一个来源**(按控件能力二选一,**绝不并集**;来源由 `resolveEntryValues` 按
1936
+ * 控件是否带 `browserModule`+`browserType` 决定,见 `browserFieldCandidates`):
1937
+ * - 控件带 `browserModule`+`browserType` ⇒ 只用该控件自己的
1938
+ * `POST /api/{module}/common/browser/complete/{type}`(服务端按关键词过滤出的列表);
1939
+ * - 否则 ⇒ 只用 `nameToId`(`getRelateData`,一个关键词回一条)。
1940
+ *
1941
+ * **采用条件**=按序判定的四档,命中即采用;**全不命中就澄清**,把去重后的全部候选交给用户确认:
1942
+ * ① **整串优先**——原串本身就是一个名称 ⇒ 用它、不拆(名称自带逗号也不会被拆错;真重名则澄清);
1943
+ * ② 候选池**恰好一条**(唯一匹配);
1944
+ * ③ 某个说法**字面完全同名**、且这样的候选恰好一条;
1945
+ * ④ 否则澄清。**没有"挑一个最接近的"这一档**。
1946
+ * 显式 ID(`名称(123)`)本身即精确值,直入候选池并按"字面命中"参与 ③,且**不发查询**。
1947
+ *
1948
+ * 之所以不再把 `getRelateData` 的"最佳匹配"直接当命中(V12.1.58 及更早的做法):它是服务端
1949
+ * 模糊匹配出的**单条**结果,与用户写的词往往并不字面相同——真机「4楼,4」被静默解析成
1950
+ * `142培训室(中庭后侧)` 且不澄清。凡是"多候选+无字面命中"就必须澄清,不再猜。
1951
+ */
1952
+ function resolveBrowserAliases(field, raw, lookup) {
1953
+ const dedupe = (items) => {
1954
+ const out = [];
1955
+ for (const item of items) {
1956
+ const id = String(item?.id ?? '').trim();
1957
+ if (!id || out.some((x) => x.id === id))
1958
+ continue;
1959
+ out.push({ id, name: String(item?.name ?? id) });
1960
+ }
1961
+ return out;
1962
+ };
1963
+ const aliases = splitValueAliases(raw);
1964
+ // 整串与各说法**并行**登记:整串命中(名称自带逗号)与切段命中进同一个池子判定
1965
+ const terms = aliases.length > 1 ? [raw, ...aliases] : [raw];
1966
+ const pool = [];
1967
+ /** 每个词各自「字面同名」的候选(显式 ID 也算字面命中,它本来就是精确值)。 */
1968
+ const literalByTerm = new Map();
1969
+ for (const term of terms) {
1970
+ const parsed = parseNameWithId(term);
1971
+ const candidates = parsed.id
1972
+ ? [{ id: String(parsed.id), name: parsed.name || String(parsed.id) }] // 显式 ID:精确值,不查名
1973
+ : (() => { const hit = lookup(term); return [...hit.mapped, ...hit.completed]; })();
1974
+ pool.push(...candidates);
1975
+ literalByTerm.set(term, candidates.filter((c) => String(c?.name ?? '') === term));
1976
+ }
1977
+ const poolHits = dedupe(pool);
1978
+ // ① **整串优先**:原串本身就是一个名称 ⇒ 用它、不拆(名称自带逗号也不会被拆错);真重名则澄清
1979
+ if (aliases.length > 1) {
1980
+ const wholeHits = dedupe(literalByTerm.get(raw) || []);
1981
+ if (wholeHits.length === 1)
1982
+ return wholeHits[0];
1983
+ if (wholeHits.length > 1)
1984
+ throw browserAmbiguity(field, raw, wholeHits);
1985
+ }
1986
+ // ② 候选池恰好一条 ⇒ 唯一匹配,采用
1987
+ if (poolHits.length === 1)
1988
+ return poolHits[0];
1989
+ // ③ 某个说法字面完全同名、且这样的候选**恰好一条** ⇒ 采用
1990
+ const literalHits = dedupe([...literalByTerm.values()].flat());
1991
+ if (literalHits.length === 1)
1992
+ return literalHits[0];
1993
+ // ④ 否则澄清:把所有去重后的候选一次交给用户确认,绝不挑一个"最接近"的蒙过去
1994
+ throw browserAmbiguity(field, raw, poolHits);
1995
+ }
2058
1996
  function normalizeRelateCandidate(value) {
2059
1997
  if (!value || typeof value !== 'object')
2060
1998
  return null;
@@ -2094,7 +2032,7 @@ function serializeValue(field, value, mappings, row, browserHints = {}) {
2094
2032
  scopeType,
2095
2033
  input: content,
2096
2034
  reason: 'NOT_RESOLVED',
2097
- resolution: `名称「${content}(${scopeType})」未能解析为 ID,请核对名称,或改用 {"optionId":"候选id","content":"候选名称","type":"${scopeType}"} 后重新 prepare。`,
2035
+ resolution: `名称「${content}(${scopeType})」未能解析。请让用户给出该对象的完整名称,或其它可用于检索的关键词(不要向用户索取 ID)。`,
2098
2036
  });
2099
2037
  }
2100
2038
  detail.dataOptions = options;
@@ -2126,18 +2064,28 @@ function serializeValue(field, value, mappings, row, browserHints = {}) {
2126
2064
  return [detail, options.map((o) => ({ id: o.optionId, name: String((o.optionObj && (o.optionObj.name || o.optionObj.content)) || o.optionId) }))];
2127
2065
  }
2128
2066
  if (String(field.componentKey ?? '').toLowerCase() === 'filecomponent') {
2067
+ // 附件字段的值支持两种形态:
2068
+ // ① 附件项数组 `[{optionId, content, type}]`——本地新上传后拼出来的;
2069
+ // ② 历史参考值**原样** `{formField:{id}, dataOptions:[{optionId, content, type}]}`——
2070
+ // `historyRequestData` 给附件字段的就是它,可整块粘进 `data`(见 workflow-create.md
2071
+ // 的「历史流程数据参考规则」第三条)。这里把 `dataOptions` 剥出来统一处理。
2072
+ const rawItems = (value && typeof value === 'object' && !Array.isArray(value)
2073
+ && Array.isArray(value.dataOptions))
2074
+ ? value.dataOptions
2075
+ : value;
2129
2076
  const options = [];
2130
- for (const item of listValues(value)) {
2077
+ for (const item of listValues(rawItems)) {
2131
2078
  if (!item || typeof item !== 'object') {
2132
- throw new ValueAmbiguity({ kind: 'ATTACHMENT', fieldId: field.id, fieldName: field.name, input: item, reason: '附件字段的值必须是附件项数组', resolution: '每项固定格式 {"optionId":"上传返回的file_id","content":"文件名","type":"file"}' });
2079
+ throw new ValueAmbiguity({ kind: 'ATTACHMENT', fieldId: field.id, fieldName: field.name, input: item, reason: '附件字段的值必须是附件项数组(或历史参考值对象)', resolution: '每项形如 {"optionId":"附件ID","content":"文件名","type":"文件类型"};复用历史附件时可直接传 historyRequestData 里该字段的值' });
2133
2080
  }
2134
2081
  const optionId = String(item.optionId ?? '').trim();
2135
2082
  const content = String(item.content ?? '').trim();
2136
- const ftype = String(item.type ?? '').trim();
2137
- if (!optionId || !content || ftype !== 'file') {
2138
- throw new ValueAmbiguity({ kind: 'ATTACHMENT', fieldId: field.id, fieldName: field.name, input: item, reason: '附件项缺少 optionId/content,或 type 不是 file', resolution: '固定格式 {"optionId":"上传返回的file_id","content":"文件名","type":"file"}' });
2083
+ if (!optionId || !content) {
2084
+ throw new ValueAmbiguity({ kind: 'ATTACHMENT', fieldId: field.id, fieldName: field.name, input: item, reason: '附件项缺少 optionId 或 content', resolution: '每项形如 {"optionId":"附件ID","content":"文件名","type":"文件类型"};optionId 必须是附件自己的 ID,不要拿文件名或其它字符串顶替' });
2139
2085
  }
2140
- options.push({ optionId, content, type: 'file' });
2086
+ // `type` **按原样透传**(图片是 image、普通文件是 file)。早先这里硬编码成 'file',
2087
+ // 会把图片附件写成错误的类型;历史数据里没有 type 时才退回 'file'。
2088
+ options.push({ optionId, content, type: String(item.type ?? '').trim() || 'file' });
2141
2089
  }
2142
2090
  assertMultiplicity(field, options);
2143
2091
  detail.dataOptions = options;
@@ -2150,30 +2098,16 @@ function serializeValue(field, value, mappings, row, browserHints = {}) {
2150
2098
  options.push({ optionId: String(item.id), content: String(item.name ?? item.content ?? item.id) });
2151
2099
  continue;
2152
2100
  }
2153
- const keyword = String(item).trim();
2101
+ const raw = String(item).trim();
2154
2102
  const rowIndex = String((row && row.dataIndex) ?? '0');
2155
- const lookupKey = `${field.id}_${rowIndex}_${keyword}`;
2156
- const candidates = mappings[lookupKey] || [];
2157
- const exact = candidates.filter((c) => c.name.toLowerCase() === keyword.toLowerCase());
2158
- const selected = exact.length === 1 ? exact : candidates;
2159
- if (selected.length !== 1) {
2160
- // 未命中时带上候选澄清清单(该浏览控件首页合法取值);一个候选都没有时如实给空数组,
2161
- // 由文档要求模型"一次明确告知用户并请其给出准确名称",禁止换相近词反复 prepare。
2162
- const hints = selected.length ? selected : (browserHints[lookupKey] || []);
2163
- throw new ValueAmbiguity({
2164
- kind: 'BROWSER',
2165
- fieldId: field.id,
2166
- fieldName: field.name,
2167
- input: keyword,
2168
- reason: selected.length ? 'MULTIPLE_MATCHES' : 'NOT_FOUND',
2169
- candidates: hints,
2170
- candidatesAvailable: hints.length > 0,
2171
- resolution: hints.length
2172
- ? '从 candidates 里挑出用户确认的那个,按 {"id":"候选id","name":"候选名称"} 重新 prepare。'
2173
- : '将该值改为 {"id":"候选id","name":"候选名称"} 后重新 prepare。',
2174
- });
2175
- }
2176
- options.push({ optionId: selected[0].id, content: selected[0].name });
2103
+ // 候选两个来源:nameToId(getRelateData)→ mapped,该控件自己的 complete 接口 → completed。
2104
+ // 两者**不做并集**(见 resolveBrowserAliases 的说明)。
2105
+ const lookup = (term) => {
2106
+ const key = `${field.id}_${rowIndex}_${term}`;
2107
+ return { mapped: mappings[key] || [], completed: browserHints[key] || [] };
2108
+ };
2109
+ const picked = resolveBrowserAliases(field, raw, lookup);
2110
+ options.push({ optionId: picked.id, content: picked.name });
2177
2111
  }
2178
2112
  assertMultiplicity(field, options);
2179
2113
  detail.dataOptions = options;
@@ -2190,7 +2124,17 @@ function serializeValue(field, value, mappings, row, browserHints = {}) {
2190
2124
  const keyword = String(item).trim();
2191
2125
  const matches = choices.filter((c) => String(c.id) === keyword || String(c.name ?? '').toLowerCase() === keyword.toLowerCase());
2192
2126
  if (matches.length !== 1) {
2193
- throw new ValueAmbiguity({ kind: 'OPTION', fieldId: field.id, fieldName: field.name, input: keyword, reason: matches.length ? 'MULTIPLE_MATCHES' : 'NOT_FOUND', candidates: matches || choices, resolution: '将该值改为 {"id":"选项id","name":"选项名称"} 后重新 prepare。' });
2127
+ throw new ValueAmbiguity({
2128
+ kind: 'OPTION',
2129
+ fieldId: field.id,
2130
+ fieldName: field.name,
2131
+ input: keyword,
2132
+ reason: matches.length ? 'MULTIPLE_MATCHES' : 'NOT_FOUND',
2133
+ candidates: matches || choices,
2134
+ resolution: matches.length
2135
+ ? '把 candidates 原样交给用户确认;用户选定后由你取该项的 id / name 拼出 {"id":…,"name":…} 重发同一条命令(不要让用户提供 ID)。'
2136
+ : '该值在系统里没有匹配。请让用户给出该选项的完整名称,或其它可用于检索的关键词(不要向用户索取 ID)。',
2137
+ });
2194
2138
  }
2195
2139
  selectedOptions.push({ optionId: matches[0].id, content: matches[0].name });
2196
2140
  }
@@ -2260,7 +2204,9 @@ async function preResolveEmployeeScopeEntries(env, entries) {
2260
2204
  input: content,
2261
2205
  reason: selected.length ? 'MULTIPLE_MATCHES' : 'NOT_FOUND',
2262
2206
  candidates: selected.map((c) => ({ id: c.id, name: c.name })),
2263
- resolution: `将该值改为 {"optionId":"候选id","content":"候选名称","type":"${scopeType}"} 后重新 prepare。`,
2207
+ resolution: selected.length
2208
+ ? `把 candidates 原样交给用户确认;用户选定后由你取该项的 id / name 拼出 {"optionId":…,"content":…,"type":"${scopeType}"} 重发同一条命令(不要让用户提供 ID)。`
2209
+ : '该名称在系统里没有匹配。请让用户给出该对象的完整名称,或其它可用于检索的关键词(不要向用户索取 ID)。',
2264
2210
  };
2265
2211
  break;
2266
2212
  }
@@ -2280,20 +2226,21 @@ async function preResolveEmployeeScopeEntries(env, entries) {
2280
2226
  }
2281
2227
  return failures;
2282
2228
  }
2229
+ /** 浏览控件候选查询的并发上限(对齐 `read.ts` 摘要补全的口径,避免大表单瞬时打爆对端)。 */
2230
+ const BROWSER_HINT_MAX_WORKERS = 5;
2283
2231
  /**
2284
- * 浏览按钮字段「名称→ID 未命中」时的候选澄清(V12.1.22 定稿)。
2232
+ * 浏览按钮字段「名称→ID 未命中」时的候选查询(V12.1.22 起,V12.1.58 改口径)。
2285
2233
  *
2286
- * 名称→ID 走的是 `nameToId`(`/api/ai/chat/fieldMapping/getRelateData`),它**一个关键词只回一个
2287
- * "最佳匹配"**,未命中时给不出任何候选 —— 于是 `ambiguities[].candidates` 只能是空数组,模型只能
2288
- * 自己换相近词瞎猜(2026-09-16「费用类型=手机话费」实证:模型猜了"手机费",最后干脆把字段删了)。
2289
- * 同文件里 **select 分支** `candidates: matches || choices` 是会回退给出**该字段全部可选项**的,
2290
- * 浏览按钮这一支缺了这个兜底。
2234
+ * 控件自带浏览接口时的**唯一**候选来源(与人员范围同一套协议):
2235
+ * `POST /api/{browserModule}/common/browser/complete/{browserType}`,服务端按关键词过滤出的列表。
2236
+ * 是否走这一路只取决于**控件有没有 `browserModule`+`browserType`**(见 `resolveEntryValues`),
2237
+ * 有则**完全不再走** `nameToId`(`/api/ai/chat/fieldMapping/getRelateData`)——它一个关键词只回
2238
+ * 一条"最佳匹配",模糊结果既给不出候选清单,又会被误当字面命中。两者的选择是**二选一**,不是并集。
2291
2239
  *
2292
- * 这里改用字段自带的浏览接口(与人员范围同一套协议)
2293
- * `POST /api/{browserModule}/common/browser/complete/{browserType}`:
2294
- * ① 先按关键词查一次(该接口与 nameToId 不是同一实现,可能命中 nameToId 命不中的值);
2295
- * ② 仍为空 → **再用空关键词**查一次,把该浏览控件首页的合法取值当候选返回,让模型/用户有据可依。
2296
- * 只对**真的没命中**的关键词发请求,命中路径零额外开销。
2240
+ * **不再用空关键词枚举该控件首页**(V12.1.22 那版会这么做):真机「会议室=4楼」实证——关键词
2241
+ * 0 命中时列出的 10 条其实是**未过滤的默认首页**,与关键词毫无关系,模型会把它当成"按关键词
2242
+ * 匹配出来的候选"转述给用户 ⇒ **误导选择**。命不中就如实给空数组,由文档要求模型"一次明确
2243
+ * 告知用户并请其给出准确名称"。
2297
2244
  */
2298
2245
  async function browserFieldCandidates(env, field, keyword) {
2299
2246
  const module = String(field?.browserModule ?? '').trim();
@@ -2338,12 +2285,9 @@ async function browserFieldCandidates(env, field, keyword) {
2338
2285
  return out;
2339
2286
  };
2340
2287
  const kw = String(keyword ?? '').trim();
2341
- const byKeyword = kw ? await fetchOnce(kw) : [];
2342
- if (byKeyword.length)
2343
- return byKeyword;
2344
- // 关键词查不到 → 取该浏览控件的合法取值清单(空关键词枚举首页)
2345
- const all = await fetchOnce('');
2346
- return all.filter((c) => c.name.toLowerCase() !== kw.toLowerCase());
2288
+ if (!kw)
2289
+ return [];
2290
+ return fetchOnce(kw);
2347
2291
  }
2348
2292
  async function resolveEntryValues(env, entries, formId) {
2349
2293
  // 人员范围先做名称→ID(它不走 nameToId,也不该被当成普通浏览按钮字段)
@@ -2365,25 +2309,39 @@ async function resolveEntryValues(env, entries, formId) {
2365
2309
  for (const item of listValues(entry.value)) {
2366
2310
  if (item && typeof item === 'object' && (item.id || item.optionId))
2367
2311
  continue;
2368
- const keyword = String(item).trim();
2369
- if (!keyword)
2370
- continue;
2371
- const lookupKey = `${field.id}_${rowIndex}_${keyword}`;
2372
- if (seenLookups.has(lookupKey))
2312
+ const raw = String(item).trim();
2313
+ if (!raw)
2373
2314
  continue;
2374
- seenLookups.add(lookupKey);
2375
- lookupSpecs.set(lookupKey, { field, keyword });
2376
- fieldDatas.push({
2377
- fieldId: String(field.id),
2378
- componentKey: field.componentKey || '',
2379
- fieldValue: keyword,
2380
- rowIndex,
2381
- operateType: 'add',
2382
- isDetail,
2383
- subFormId: String(field.subFormId || '0'),
2384
- browserModule: field.browserModule || '',
2385
- browserType: field.browserType || '',
2386
- });
2315
+ // 一个格子可写多个「说法」(逗号分隔):**整串与各说法一起登记**——这样"整串优先"要用的
2316
+ // 整串候选、以及各说法各自的候选,都在同一批里拿到(nameToId 一把批量发,未命中的再并行补)。
2317
+ const aliases = splitValueAliases(raw);
2318
+ const terms = aliases.length > 1 ? [raw, ...aliases] : [raw];
2319
+ // 候选来源二选一:控件自带 `browserModule`+`browserType` ⇒ 只走 complete(下一步补查),
2320
+ // **不进 nameToId 批次**;否则只走 nameToId。这样池子里永远只有一个来源,不会互相污染。
2321
+ const hasBrowserChannel = Boolean(field.browserModule && field.browserType);
2322
+ for (const term of terms) {
2323
+ // 显式 ID(`名称(ID)`,括号内纯数字)不必查名:resolveBrowserAliases 会直接采用它。
2324
+ if (parseNameWithId(term).id)
2325
+ continue;
2326
+ const lookupKey = `${field.id}_${rowIndex}_${term}`;
2327
+ if (seenLookups.has(lookupKey))
2328
+ continue;
2329
+ seenLookups.add(lookupKey);
2330
+ lookupSpecs.set(lookupKey, { field, keyword: term });
2331
+ if (hasBrowserChannel)
2332
+ continue;
2333
+ fieldDatas.push({
2334
+ fieldId: String(field.id),
2335
+ componentKey: field.componentKey || '',
2336
+ fieldValue: term,
2337
+ rowIndex,
2338
+ operateType: 'add',
2339
+ isDetail,
2340
+ subFormId: String(field.subFormId || '0'),
2341
+ browserModule: field.browserModule || '',
2342
+ browserType: field.browserType || '',
2343
+ });
2344
+ }
2387
2345
  }
2388
2346
  }
2389
2347
  const mappings = {};
@@ -2407,17 +2365,28 @@ async function resolveEntryValues(env, entries, formId) {
2407
2365
  }
2408
2366
  }
2409
2367
  }
2410
- // 名称→ID 未命中的浏览按钮:补一次候选澄清(只对真的没命中的关键词发请求)。
2411
- // 有了候选清单,模型的 `ambiguities[].resolution` 才有据可依(把合法取值列给用户选),
2368
+ // 自带浏览接口的控件:按关键词查一次 complete 拿候选清单(这是该类控件的**唯一**候选来源)。
2369
+ // 有了候选清单,模型的 `ambiguities[].resolution` 才有据可依(把真实命中列给用户选),
2412
2370
  // 而不是拿到空数组后自己换相近词硬猜(见 browserFieldCandidates 的说明)。
2413
2371
  const browserHints = {};
2372
+ const pendingHints = [];
2414
2373
  for (const [key, spec] of lookupSpecs) {
2415
- if (mappings[key] && mappings[key].length)
2416
- continue;
2417
2374
  if (!spec.field.browserModule || !spec.field.browserType)
2418
2375
  continue;
2419
- browserHints[key] = await browserFieldCandidates(env, spec.field, spec.keyword);
2420
- }
2376
+ pendingHints.push([key, spec]);
2377
+ }
2378
+ // 「整串 + 各说法」的候选查询**并行**发出(上限 BROWSER_HINT_MAX_WORKERS):
2379
+ // 串行会让一个格子写多个说法时白等好几轮往返。
2380
+ let hintCursor = 0;
2381
+ const hintWorker = async () => {
2382
+ while (hintCursor < pendingHints.length) {
2383
+ const [key, spec] = pendingHints[hintCursor];
2384
+ hintCursor += 1;
2385
+ browserHints[key] = await browserFieldCandidates(env, spec.field, spec.keyword);
2386
+ }
2387
+ };
2388
+ const hintWorkers = Math.max(1, Math.min(pendingHints.length, BROWSER_HINT_MAX_WORKERS));
2389
+ await Promise.all(Array.from({ length: hintWorkers }, () => hintWorker()));
2421
2390
  const details = [];
2422
2391
  const ambiguities = [];
2423
2392
  const preview = [];
@@ -3333,87 +3302,12 @@ function linkageResultNotice(linkageDetails, fields, hiddenSubforms, rowHidden,
3333
3302
  // ---------------------------------------------------------------------------
3334
3303
  // createRequest(合并原 createPrepare + createApply)
3335
3304
  // ---------------------------------------------------------------------------
3336
- /**
3337
- * 把 preview 里的字段值渲染成人类可读文本(写进记忆偏好用)。
3338
- *
3339
- * `preview[].value` 的形态随字段类型而变:文本是字符串、选项是字符串、浏览/人员/部门是
3340
- * `{id,name}`(或数组)。这里只取可读的名字部分,避免把 `[object Object]` 写进记忆。
3341
- */
3342
- function preferenceTextOf(value) {
3343
- if (value === undefined || value === null)
3344
- return '';
3345
- if (Array.isArray(value)) {
3346
- return value
3347
- .map((item) => String((item && typeof item === 'object') ? (item.name ?? item.content ?? '') : item).trim())
3348
- .filter(Boolean)
3349
- .join('、');
3350
- }
3351
- if (typeof value === 'object')
3352
- return '';
3353
- return String(value).trim();
3354
- }
3355
- /**
3356
- * 保存成功后把「本次必填字段的取值」沉淀为该工作流的发起偏好(`agentMemoryExtract`)。
3357
- *
3358
- * **放在 CLI 内部做**(模型无感):这是「这次必填项填了什么」这类确定性事实,不是需要语义抽取的
3359
- * 自由文本——由命令自己记下来最可靠,也省掉「模型任务结束时再补一次 memoryExtract」这一轮往返。
3360
- * best-effort:失败只返回 `false`,绝不影响发起结果。
3361
- *
3362
- * 2026-09-23:**静默化** —— 调用方**不再把返回值写进任何输出字段**(原先回 `memorySaved`)。
3363
- * 返回值仍是 `boolean`,仅保留给单元测试/内部判断用;路由层不得再把它暴露给模型。
3364
- */
3365
- async function saveLaunchPreferences(workflow, requiredFieldIds, preview, env) {
3366
- const pairs = [];
3367
- const seen = new Set();
3368
- for (const item of preview || []) {
3369
- const fieldId = String(item?.fieldId ?? '').trim();
3370
- if (!fieldId || !requiredFieldIds.has(fieldId) || seen.has(fieldId))
3371
- continue;
3372
- const text = preferenceTextOf(item.value);
3373
- if (!text)
3374
- continue;
3375
- seen.add(fieldId);
3376
- pairs.push(`${String(item.fieldName ?? fieldId).trim()}=${text}`);
3377
- }
3378
- if (!pairs.length)
3379
- return false;
3380
- const workflowId = String(workflow.id ?? '').trim();
3381
- const workflowName = String(workflow.name ?? '').trim();
3382
- try {
3383
- const response = await env.host.workflowRequest({
3384
- method: 'POST',
3385
- path: WORKFLOW_ENDPOINTS.MEMORY_EXTRACT,
3386
- operation: 'workflow.createRequest',
3387
- body: {
3388
- chatParams: {
3389
- // appid 必须始终携带(缺省时记忆接口判为无效应用、静默不保存)
3390
- appid: resolveMemoryAppId(),
3391
- dynamicContextParams: { ...(workflowId ? { workflowId } : {}) },
3392
- },
3393
- // 末尾补 remember 保存触发信号词(与 workflow.memoryExtract 同口径)
3394
- aiMsgEntityList: [
3395
- { msgRole: 'user', msgValue: `记住:发起「${workflowName || workflowId}」流程时,这些必填项的常用值为 ${pairs.join(';')}。` },
3396
- { msgRole: 'user', msgValue: 'remember' },
3397
- ],
3398
- },
3399
- });
3400
- // 记忆接口业务标志是 status/fail(不是 success);只在明确失败时判未保存
3401
- const body = (response && typeof response === 'object' ? response : {});
3402
- if (body.saved === false || body.success === false || body.status === false || body.fail === true)
3403
- return false;
3404
- return body.saved === true || body.success === true || body.status === true || body.code === 200 || body.fail === false;
3405
- }
3406
- catch {
3407
- return false;
3408
- }
3409
- }
3410
3305
  /**
3411
3306
  * `workflow.createRequest` —— **一条命令**完成「字段解析 → 联动 → 必填校验 → 保存 → 发起」。
3412
3307
  *
3413
3308
  * 模型只需按用户意图把字段填好直接调用,**不需要自己判断必填是否缺失**:
3414
3309
  * - `action=submit` 且仍有必填缺失 → **一个写请求都不发**,把全部缺失项一次交回(`NEEDS_INPUT`);
3415
- * - `action=draft` → 直接创建草稿,缺失项仅作提醒随结果返回(**含联动后才变成必填的字段**);
3416
- * - 成功保存后,CLI **内部**把必填字段的本次取值沉淀为该工作流的偏好(模型无感)。
3310
+ * - `action=draft` → 直接创建草稿,缺失项仅作提醒随结果返回(**含联动后才变成必填的字段**)。
3417
3311
  *
3418
3312
  * 句柄消费时机(关键):**只在真正要发写入请求之前**消费。缺必填 / 需澄清这类「还没写」的
3419
3313
  * 返回**不消费**句柄,于是模型补齐字段后可以带**同一个 context** 重发,不必回头重跑 createForm;
@@ -3495,6 +3389,26 @@ async function commandCreateRequest(args, env) {
3495
3389
  linkageRequiredIds.add(String(fieldId));
3496
3390
  }
3497
3391
  const missingAll = [...missing, ...systemMissing];
3392
+ /**
3393
+ * 历史参考值(V12.1.77):取数已在 `createForm` 做完,这里**只从句柄里的缓存筛**,不再联网。
3394
+ *
3395
+ * 只筛「**这次新出现**的必填字段」——即 `createForm` 当时还没被判成必填、模型因此没见过的那些
3396
+ * (绝大多数是联动命中后才变成必填的字段)。`createForm` 已经给过历史值的字段不再重复返回:
3397
+ * 那些值模型本来就拿到了,重复给只是白占上下文。
3398
+ *
3399
+ * `createForm` 缓存是"本表单全部字段"的宽结果(`allFormFields`),所以新字段也能命中;
3400
+ * 缓存取不到 / 一个值都没命中 ⇒ `null`(不塞空数组)。句柄不是本机签发、或会话跨版本升级时
3401
+ * 缓存可能缺失,此时退化为 `null`——纯辅助信息,不阻断任何流程。
3402
+ */
3403
+ const knownRequiredFieldIds = new Set(toArray(context.historyRequiredFieldIds).map((id) => String(id ?? '').trim()).filter(Boolean));
3404
+ const newMissingFieldIds = new Set();
3405
+ for (const item of missingAll) {
3406
+ const fieldId = String(item?.id ?? '').trim();
3407
+ if (!fieldId || item?.systemField || knownRequiredFieldIds.has(fieldId))
3408
+ continue;
3409
+ newMissingFieldIds.add(fieldId);
3410
+ }
3411
+ const historyRequestData = filterHistoryRequestData(context.historyCache, newMissingFieldIds);
3498
3412
  if (valueAmbiguities.length) {
3499
3413
  return workflowSuccess('workflow.createRequest', {
3500
3414
  status: 'NEEDS_CONFIRMATION',
@@ -3506,6 +3420,7 @@ async function commandCreateRequest(args, env) {
3506
3420
  resolvedValues: preview,
3507
3421
  ambiguities: valueAmbiguities,
3508
3422
  missingFields: missingAll,
3423
+ historyRequestData,
3509
3424
  linkageResult,
3510
3425
  ...folderPayload,
3511
3426
  });
@@ -3518,6 +3433,7 @@ async function commandCreateRequest(args, env) {
3518
3433
  message: '缺少必填字段,本次未发起流程。请把下面这些字段一次问齐后,带同一 context 重发 action=submit。',
3519
3434
  resolvedValues: preview,
3520
3435
  missingFields: missingAll,
3436
+ historyRequestData,
3521
3437
  linkageResult,
3522
3438
  ...folderPayload,
3523
3439
  });
@@ -3588,18 +3504,7 @@ async function commandCreateRequest(args, env) {
3588
3504
  }
3589
3505
  throw error;
3590
3506
  }
3591
- const submitIntent = action === 'submit';
3592
- let verification = {};
3593
- let needConfirm = false;
3594
- if (submitIntent) {
3595
- verification = await runCreateNodeFieldVerification(env, context);
3596
- const reminders = verification.reminders || [];
3597
- needConfirm = verification.enabled
3598
- ? (Boolean(reminders.length && verification.noPassPrompt === 1) || (!reminders.length && verification.passPopUpPrompt === 1))
3599
- : false;
3600
- }
3601
- const createSrc = (submitIntent && !verification.blocked && !(needConfirm && args.verificationConfirmed !== true)) ? 'submit' : 'save';
3602
- // 提交时字段联动(EVENT_SUBMIT_OR_AGREE)
3507
+ // 提交时字段联动(EVENT_SUBMIT_OR_AGREE)——要在 create 之前跑完(可能改 dataDetails/必填)
3603
3508
  if (Array.isArray(context.linkageActions) && context.linkageActions.length) {
3604
3509
  const submitDetails = prepared.dataDetails || [];
3605
3510
  const submitEntries = _entriesFromDefaults(submitDetails);
@@ -3614,33 +3519,62 @@ async function commandCreateRequest(args, env) {
3614
3519
  }
3615
3520
  }
3616
3521
  }
3522
+ // ── ① 先建实例:**一律 src='save'** ────────────────────────────────────────
3523
+ // 「创建」与「要不要提交」彻底解耦,与真机同序(2026-09-28 HAR:`flow/create{src:'save'}` →
3524
+ // 节点校验 → `flow/submit` 三次独立调用)。这样只要这一步成功,流程单据**就已经存在**;
3525
+ // 之后任何环节出问题都不会再出现"发起失败且查无此单"。
3526
+ // 旧实现把 src 与节点校验结果绑死在同一次 create 上,校验一报错就**连实例都没建**——那正是
3527
+ // "草稿查不到"的根因。
3617
3528
  let created;
3618
3529
  try {
3619
3530
  const createResponse = await env.host.workflowRequest({
3620
3531
  method: 'POST',
3621
3532
  path: FORM_ENDPOINTS.create,
3622
3533
  query: AJAX_QUERY,
3623
- body: buildCreateBody(context, resolvePrepared(context, prepared), saveData, createSrc),
3534
+ body: buildCreateBody(context, resolvePrepared(context, prepared), saveData, 'save'),
3624
3535
  operation: 'workflow.createRequest',
3625
3536
  });
3626
3537
  // parseCreateResponse 需读外层 status/code 并向下遍历 data/result,必须传原始响应
3627
3538
  created = parseCreateResponse(createResponse, context.runtime.request_id);
3628
- if (created.outcome === 'created') {
3629
- // flow/create 已返回成功:阶段推进到 committed。若进程在"回写成功结果"之前被中止,
3630
- // 重放可据此报幂等成功(ALREADY_APPLIED + requestId),而不是只报一句"已使用"让人反查。
3631
- await env.continuation
3632
- .markStage(token, 'committed', { requestId: context.runtime.request_id })
3633
- .catch(() => undefined);
3634
- }
3635
- // outcome === 'rejected' 时**不**标 committed:服务端并没有给"成功回执",
3636
- // 标了会让后续重放报出"上一次已成功"的假消息。结果照常由 settle() 收尾。
3637
3539
  }
3638
3540
  catch (error) {
3639
3541
  if (isNetworkError(error)) {
3640
- throw workflowPartialError('saveFormData 已成功,但 flow/create 结果失败或不确定;禁止自动重试。', { stage: 'flow/create', ...saveData }, { stage: 'flow/create' });
3542
+ throw workflowPartialError('saveFormData 已成功,但 flow/create 结果失败或不确定。请把该结果如实告知用户并停下,不要自行重试、也不要改用其它命令去推断。', { stage: 'flow/create', ...saveData }, { stage: 'flow/create' });
3641
3543
  }
3642
3544
  throw error;
3643
3545
  }
3546
+ if (created.outcome === 'rejected') {
3547
+ // 实例**没建出来**(服务端在创建时就明确拒绝并给了原因)。此时什么都不存在,如实报失败即可;
3548
+ // 绝不返回"已创建"字样的信封——那会让用户与模型都以为有草稿可查。
3549
+ throw workflowApiError('create_rejected', `流程创建被服务端拒绝,本次未生成流程单据。原因:${created.reason}。`
3550
+ + '请把该结果如实告知用户,不要自行重试或改用其它命令推断。', { stage: 'flow/create', details: { reason: created.reason, resultType: created.resultType } });
3551
+ }
3552
+ // ── ② 实例已在:提交时重载提交页上下文(view 页鉴权 + 节点校验开关)→ 校验 → 提交 ──
3553
+ const submitIntent = action === 'submit';
3554
+ let verification = {};
3555
+ let needConfirm = false;
3556
+ let submitOutcome = null;
3557
+ if (submitIntent) {
3558
+ const submitContext = await loadCreateSubmitContext(env, context, prepared.requestLevel);
3559
+ verification = await runCreateNodeFieldVerification(env, context, {
3560
+ openVerifictionNodeField: submitContext.openVerifictionNodeField,
3561
+ popUpPromptVerifictionNodeField: submitContext.popUpPromptVerifictionNodeField,
3562
+ requestLevel: submitContext.requestLevel,
3563
+ });
3564
+ const reminders = verification.reminders || [];
3565
+ needConfirm = verification.enabled
3566
+ ? (Boolean(reminders.length && verification.noPassPrompt === 1) || (!reminders.length && verification.passPopUpPrompt === 1))
3567
+ : false;
3568
+ // 只有"该提交"才提交:被校验拦下、或需要用户确认而未确认时,**停在草稿**。
3569
+ if (!verification.blocked && !(needConfirm && args.verificationConfirmed !== true)) {
3570
+ submitOutcome = await submitCreatedRequest(env, context, prepared, submitContext);
3571
+ }
3572
+ }
3573
+ // 整条链路(创建 + 可能有的提交)都收尾了才推进到 committed:此后再被中止,重放可报幂等成功。
3574
+ // 若提前标(旧实现在 create 一成功就标),"只建了草稿、提交没成"会被重放报成"上一次已成功"。
3575
+ await env.continuation
3576
+ .markStage(token, 'committed', { requestId: context.runtime.request_id })
3577
+ .catch(() => undefined);
3644
3578
  const requestId = context.runtime.request_id;
3645
3579
  const baseUrl = (await getSessionContext(env)).baseUrl;
3646
3580
  const result = {
@@ -3650,8 +3584,8 @@ async function commandCreateRequest(args, env) {
3650
3584
  requestId,
3651
3585
  requestName: prepared.requestName,
3652
3586
  requestId_alt: requestId,
3653
- requestMark: (created.outcome === 'created' && created.requestMark) || null,
3654
- requestName_alt: (created.outcome === 'created' && created.requestName) || prepared.requestName,
3587
+ requestMark: created.requestMark || null,
3588
+ requestName_alt: created.requestName || prepared.requestName,
3655
3589
  workflow: context.workflow,
3656
3590
  url: `${baseUrl}/sp/workflow/flowpage/view/${requestId}?operResultType=SUCCESS_RELOAD_CURRENT_PAGE&userSpecifiedNodeId=0&autoMaxDisplay=0`,
3657
3591
  linkageResult,
@@ -3664,18 +3598,7 @@ async function commandCreateRequest(args, env) {
3664
3598
  previewDetails: prepared.previewDetails,
3665
3599
  };
3666
3600
  if (submitIntent) {
3667
- if (created.outcome === 'rejected' && createSrc === 'submit') {
3668
- // 服务端明确拒绝提交,但**表单数据已落库、流程以草稿形态存在**(真机实测:被拒后该
3669
- // requestId 在 workflow.detail 里查得到,flowStatus=草稿)。故按"完成 + 提交结果失败"
3670
- // 返回,而不是抛错——抛错会让模型以为"什么都没做成"而重发,一路堆积重复草稿。
3671
- // 所有"成功时会给的创建信息"这里一个都不少,只是提交结果是失败。
3672
- result.status = 'CREATED_SUBMIT_FAILED';
3673
- result.message = `流程已创建为草稿,但提交被服务端拒绝(未进入审批)。原因:${created.reason}`;
3674
- result.submitResult = 'failed';
3675
- result.submitFailReason = created.reason;
3676
- result.verification = verification;
3677
- }
3678
- else if (verification.blocked) {
3601
+ if (verification.blocked) {
3679
3602
  result.message = '流程已创建,但因节点字段校验未通过未提交审批,请调整后重新提交。';
3680
3603
  result.verification = verification;
3681
3604
  }
@@ -3685,6 +3608,16 @@ async function commandCreateRequest(args, env) {
3685
3608
  result.verification = verification;
3686
3609
  result.nodeId = context.runtime.node_id;
3687
3610
  }
3611
+ else if (!submitOutcome || !submitOutcome.ok) {
3612
+ // 提交没成功,但**流程单据已经建出来了**(第 ① 步已成功)。如实回报"创建成功 + 提交失败",
3613
+ // 不把它复述成整条命令失败,也**不提示模型去自行判断/重发**——用户看到的就是真实状态。
3614
+ const reason = (submitOutcome && submitOutcome.reason) || '服务端未给出原因';
3615
+ result.status = 'CREATED_SUBMIT_FAILED';
3616
+ result.message = `流程已创建为草稿,但提交未成功(未进入审批)。原因:${reason}`;
3617
+ result.submitResult = 'failed';
3618
+ result.submitFailReason = reason;
3619
+ result.verification = verification;
3620
+ }
3688
3621
  else {
3689
3622
  result.status = 'CREATED_SUBMITTED';
3690
3623
  result.message = '流程已发起并提交审批。';
@@ -3704,27 +3637,6 @@ async function commandCreateRequest(args, env) {
3704
3637
  : '。');
3705
3638
  }
3706
3639
  }
3707
- // 非"本次真发起提交"的路径(action=draft,或被节点校验拦下、未真正提交)里 flow/create 也被拒:
3708
- // 不改这些分支的状态语义(它们本就描述"未提交审批"),但把服务端给的原因附上,避免信息静默丢失。
3709
- if (created.outcome === 'rejected' && !(submitIntent && createSrc === 'submit')) {
3710
- result.createRejectedReason = created.reason;
3711
- result.message = `${result.message}(服务端在创建时返回:${created.reason})`;
3712
- }
3713
- // 必填字段的本次取值 → 该工作流的发起偏好(CLI 内部完成、**静默**,见下方调用点注释)
3714
- const requiredFieldIds = new Set();
3715
- for (const field of fields) {
3716
- if (field.readOnly || field.disableEdit)
3717
- continue;
3718
- if (field.required)
3719
- requiredFieldIds.add(String(field.id ?? ''));
3720
- }
3721
- for (const fieldId of linkageRequiredIds)
3722
- requiredFieldIds.add(fieldId);
3723
- // 2026-09-23 用户裁定:必填偏好的沉淀必须**静默** —— 只调接口,**不写进任何返回字段**
3724
- // (此前会回 `memorySaved`,等于把这个内部副作用暴露给模型:可被复述、可被据以推断"已经记过了")。
3725
- // 它的用途只是「后续发起同类流程时,可直接用历史偏好补齐必填项」——减少对用户的打扰、提升发起速度,
3726
- // 属于纯 CLI 内部行为,因此**输出里不得出现任何痕迹**。best-effort:失败也不影响发起结果。
3727
- await saveLaunchPreferences(context.workflow || {}, requiredFieldIds, preview, env);
3728
3640
  return workflowSuccess('workflow.createRequest', result);
3729
3641
  }
3730
3642
  function formatRemark(value) {
@@ -3835,6 +3747,104 @@ function buildCreateBody(context, prepared, saved, src) {
3835
3747
  clientType: 0,
3836
3748
  };
3837
3749
  }
3750
+ /**
3751
+ * `flow/submit` 的请求体(真机逐字段照抄:2026-09-28 HAR `POST /api/workflow/core/flow/submit?requestId=<id>`)。
3752
+ *
3753
+ * 与 `buildCreateBody` 的差异,正是"创建"与"提交"的差异:
3754
+ * - 不带 `requestName`/`dataId`/`recordId`/`saveFormTime`(数据已在库,不必再随包传);
3755
+ * - `isCreate: false`,`pageKey` 是 **view 页**的 `view_<requestId>_<ts>`;
3756
+ * - 鉴权用**创建成功后重载**拿到的 view 页那一份(见 `loadCreateSubmitContext`);
3757
+ * - 多带 `operResultType`/`userSpecifiedNodeId`/`needAutoOpen`/`autoMaxDisplay`/`testParam`/
3758
+ * `seconedSubmit`(字段名本来就少一个 n,照抄不改)。
3759
+ */
3760
+ function buildSubmitBody(context, prepared, view) {
3761
+ const runtime = context.runtime;
3762
+ const requestId = String(runtime.request_id ?? '');
3763
+ const remark = formatRemark(prepared.remark);
3764
+ return {
3765
+ operResultType: 'SUCCESS_RELOAD_CURRENT_PAGE',
3766
+ userSpecifiedNodeId: '0',
3767
+ needAutoOpen: '1',
3768
+ autoMaxDisplay: '0',
3769
+ isCreate: false,
3770
+ requestId,
3771
+ apiModule: 'workflow',
3772
+ identityId: runtime.identity_id,
3773
+ identityType: runtime.identity_type,
3774
+ workflowId: context.workflow.id,
3775
+ userCurrentNodeId: runtime.node_id,
3776
+ isAgent: false,
3777
+ beAgentId: '0',
3778
+ fixedNodeId: runtime.node_id,
3779
+ authStr: view.authStr,
3780
+ authSignatureStr: view.authSignatureStr,
3781
+ isTest: false,
3782
+ pageKey: `view_${requestId}_${Date.now()}`,
3783
+ requestLevel: String(prepared.requestLevel ?? '0').trim(),
3784
+ secLevel: String(prepared.secLevel ?? '0').trim(),
3785
+ secValidity: '',
3786
+ needWfBack: true,
3787
+ sameOperateId: null,
3788
+ customBtnId: null,
3789
+ confirmSecondSubmit: false,
3790
+ selectFlowParam: null,
3791
+ freeChoiceNodeIds: null,
3792
+ nodeAddOperators: null,
3793
+ testParam: {},
3794
+ src: 'submit',
3795
+ voteSubmit: false,
3796
+ seconedSubmit: false,
3797
+ remark,
3798
+ attachments: [],
3799
+ fileData: [],
3800
+ locationData: {},
3801
+ comment: {
3802
+ relevanceList: [],
3803
+ imAtContent: remark !== '_weaverMte_' ? remark : '',
3804
+ originContent: remark !== '_weaverMte_' ? remark : '',
3805
+ atLinkList: [],
3806
+ content: remark,
3807
+ },
3808
+ quoteData: [],
3809
+ fullTextAnnotationData: [],
3810
+ signatureData: { signatureImgId: '' },
3811
+ userSignatureData: { signatureImgId: '' },
3812
+ sealId: null,
3813
+ unNeedVerifyPrivacyAgreement: 1,
3814
+ clientType: 0,
3815
+ };
3816
+ }
3817
+ /**
3818
+ * 提交结果的成败判定——信号口径见 `isSubmitRejected`(**与创建侧不同**,不可复用 `isCreateRejected`)。
3819
+ * 失败原因复用 `pickCreateRejectReason` 的"越具体越优先"取法。
3820
+ */
3821
+ function parseSubmitResponse(response) {
3822
+ if (!response || typeof response !== 'object')
3823
+ return { ok: false, reason: '服务端未返回可识别的响应' };
3824
+ const record = response;
3825
+ if (isSubmitRejected(record))
3826
+ return { ok: false, reason: pickCreateRejectReason(record) };
3827
+ return { ok: true, reason: '' };
3828
+ }
3829
+ /**
3830
+ * `flow/submit` 的**明确拒绝**判定——**不能照搬 `isCreateRejected`**。
3831
+ *
3832
+ * 那套口径里的 `flowIsCreateSuccess === false` 是**创建语义**:该字段的含义是「本次调用有没有
3833
+ * 创建流程实例」。`flow/create` 成功时它是 `true`,而在 `flow/submit` 的**成功**响应里天然是
3834
+ * `false`(2026-09-28 真机抓包:`status:true`、`resultType:"SUCCESS_READ_PATH_CONFIG"`、
3835
+ * `isDraft:false`、`flowIsCreateSuccess:false`,流程实际已进入审批)。照搬 ⇒ **每次提交都被判成
3836
+ * 失败**(V12.1.59~61 的 `CREATED_SUBMIT_FAILED` 误报,且"原因"会取到一句写着"流程提交成功"的
3837
+ * 壳文案),进而诱导模型/用户重复提交。
3838
+ *
3839
+ * 因此只保留在**提交**响应里语义成立的三条信号:`status` / `resultType` 才是这个接口的成败口径。
3840
+ */
3841
+ function isSubmitRejected(response) {
3842
+ if (response.fail === true)
3843
+ return true;
3844
+ if (response.status === false)
3845
+ return true;
3846
+ return String(response.resultType ?? '').trim().toUpperCase().startsWith('FAIL');
3847
+ }
3838
3848
  function parseSaveResponse(response) {
3839
3849
  const data = responseData(response);
3840
3850
  if (!data || typeof data !== 'object')
@@ -3945,7 +3955,112 @@ function parseCreateResponse(response, expectedRequestId) {
3945
3955
  requestName: firstText('requestName', 'requestname'),
3946
3956
  };
3947
3957
  }
3948
- async function runCreateNodeFieldVerification(env, context) {
3958
+ /**
3959
+ * 流程实例创建**之后**重载"提交页上下文"(真机同序:`flow/create` 成功后页面整个重载)。
3960
+ *
3961
+ * 一次拿齐两样东西:
3962
+ * - `loadBaseParam?isCreate=0&requestId=<id>` → `data.commonParam.authStr`/`authSignatureStr`:
3963
+ * **view 页**鉴权(`flow/submit` 必须用它;沿用创建页的 authStr 服务端会拒);
3964
+ * - `loadMinorInfo?isCreate=0&requestId=<id>` → `data.params.openVerifictionNodeField` +
3965
+ * `popUpPromptVerifictionNodeField`:节点字段校验的两级开关。
3966
+ *
3967
+ * 两请求**并行 best-effort**:任一失败都不阻断(鉴权缺失时提交由服务端拒绝,届时按"提交失败"
3968
+ * 如实回报即可;开关缺失按"未开启节点字段校验"处理,宁少校验不误拦)。
3969
+ */
3970
+ async function loadCreateSubmitContext(env, context, requestLevel) {
3971
+ const runtime = context.runtime;
3972
+ const requestId = String(runtime.request_id ?? '');
3973
+ const query = { isCreate: '0', requestId };
3974
+ const body = {
3975
+ operResultType: 'SUCCESS_RELOAD_CURRENT_PAGE',
3976
+ userSpecifiedNodeId: '0',
3977
+ needAutoOpen: '1',
3978
+ autoMaxDisplay: '0',
3979
+ isCreate: 0,
3980
+ requestId,
3981
+ };
3982
+ const [base, minor] = await Promise.all([
3983
+ requestJson(env, 'POST', FORM_ENDPOINTS.loadBase, query, body).catch(() => null),
3984
+ requestJson(env, 'POST', FORM_ENDPOINTS.loadMinor, query, body).catch(() => null),
3985
+ ]);
3986
+ const commonParam = (base && typeof base === 'object' && typeof base.commonParam === 'object')
3987
+ ? base.commonParam
3988
+ : {};
3989
+ const params = (minor && typeof minor === 'object' && typeof minor.params === 'object')
3990
+ ? minor.params
3991
+ : {};
3992
+ const pv = (params.popUpPromptVerifictionNodeField && typeof params.popUpPromptVerifictionNodeField === 'object')
3993
+ ? params.popUpPromptVerifictionNodeField
3994
+ : {};
3995
+ return {
3996
+ authStr: String(commonParam.authStr ?? runtime.auth_str ?? ''),
3997
+ authSignatureStr: String(commonParam.authSignatureStr ?? runtime.auth_signature ?? ''),
3998
+ openVerifictionNodeField: asBool(params.openVerifictionNodeField, false),
3999
+ popUpPromptVerifictionNodeField: pv,
4000
+ requestLevel: String(requestLevel ?? '0').trim(),
4001
+ };
4002
+ }
4003
+ /**
4004
+ * 提交**已创建**的流程(真机同序:`flow/create{src:'save'}` 之后单独调 `flow/submit`)。
4005
+ *
4006
+ * **不抛错、只返回成败**:流程单据此刻已经存在,提交失败只是"提交没成",把整条命令报成失败会
4007
+ * 让"创建成功"这个事实丢失(那正是 A.100 里"发起失败且查无此单"的反面)。
4008
+ * 连接中断同样按"提交结果不确定"如实回报;**认证错误照规则原样抛**,不降级成数据结论。
4009
+ */
4010
+ async function submitCreatedRequest(env, context, prepared, view) {
4011
+ let response;
4012
+ try {
4013
+ response = await env.host.workflowRequest({
4014
+ method: 'POST',
4015
+ path: FORM_ENDPOINTS.flowSubmit,
4016
+ query: { requestId: String(context.runtime.request_id ?? '') },
4017
+ body: buildSubmitBody(context, prepared, view),
4018
+ operation: 'workflow.createRequest',
4019
+ });
4020
+ }
4021
+ catch (error) {
4022
+ rethrowAuthOrNetwork(error);
4023
+ return { ok: false, reason: '提交请求发送后连接中断,提交结果不确定' };
4024
+ }
4025
+ return parseSubmitResponse(response);
4026
+ }
4027
+ /**
4028
+ * 节点字段校验的**第一跳**:只问服务端「要不要把校验结果展示给用户」。
4029
+ *
4030
+ * 请求体逐字段照真机:`identityId`/`requestName`/`requestLevelName` 固定空串,`cptSetList` 就是
4031
+ * `loadMinorInfo` 原样给的那一份(**透传不加工**)。返回 `data` 是布尔——真 ⇒ 需要展示 ⇒ 才值得
4032
+ * 去取详细规则集逐条算。
4033
+ */
4034
+ async function verifyNodeFieldByList(env, context, switches) {
4035
+ const runtime = context.runtime;
4036
+ const data = await requestJson(env, 'POST', FORM_ENDPOINTS.verifyNodeFieldByList, AJAX_QUERY, {
4037
+ requestId: String(runtime.request_id ?? ''),
4038
+ nodeId: String(runtime.node_id ?? ''),
4039
+ passPopUpPrompt: switches.passPopUpPrompt,
4040
+ noPassPrompt: switches.noPassPrompt,
4041
+ requestLevel: switches.requestLevel,
4042
+ requestLevelName: '',
4043
+ identityId: '',
4044
+ requestName: '',
4045
+ ruleRelation: switches.ruleRelation,
4046
+ isTest: 0,
4047
+ isMobile: false,
4048
+ testUserId: String(runtime.employee_id ?? ''),
4049
+ cptSetList: switches.cptSetList,
4050
+ });
4051
+ return data === true;
4052
+ }
4053
+ /**
4054
+ * 节点字段校验(发起侧)。**三级门,顺序不可换**(2026-09-28 两份真机 HAR 实证):
4055
+ * ① `openVerifictionNodeField` 为假 ⇒ 该流程没开节点字段校验,整段跳过;
4056
+ * ② `passPopUpPrompt`、`noPassPrompt` **不都为真** ⇒ 先调 `verifyNodeFieldByList`,用它的布尔
4057
+ * 结果当"要不要展示";为假 ⇒ 无需展示、整段跳过。**「会议申请」流程正是此路**(两个开关都是
4058
+ * false):旧实现跳过这一跳直接去取规则集,被服务端以"系统错误"拒掉,且因为它在 `flow/create`
4059
+ * 之前执行,连流程实例都没建出来;
4060
+ * ③ 需要展示 ⇒ 才取 `getConformanceSetByVerification` + `getRequestDataByRequestId` + 逐条
4061
+ * `verificationConditionRule`。「销售合同」流程(两个开关都是 true,规则 15 条)走此路。
4062
+ */
4063
+ async function runCreateNodeFieldVerification(env, context, switches) {
3949
4064
  const runtime = context.runtime;
3950
4065
  const requestId = String(runtime.request_id ?? '').trim();
3951
4066
  const nodeId = String(runtime.node_id ?? '').trim();
@@ -3954,6 +4069,24 @@ async function runCreateNodeFieldVerification(env, context) {
3954
4069
  result.error = '缺少 requestId 或 nodeId,跳过节点字段校验';
3955
4070
  return result;
3956
4071
  }
4072
+ if (!switches.openVerifictionNodeField)
4073
+ return result;
4074
+ const pv = (switches.popUpPromptVerifictionNodeField && typeof switches.popUpPromptVerifictionNodeField === 'object')
4075
+ ? switches.popUpPromptVerifictionNodeField
4076
+ : {};
4077
+ const passPopUpPrompt = Number(pv.passPopUpPrompt ?? 0);
4078
+ const noPassPrompt = Number(pv.noPassPrompt ?? 0);
4079
+ if (!passPopUpPrompt || !noPassPrompt) {
4080
+ const showDialog = await verifyNodeFieldByList(env, context, {
4081
+ passPopUpPrompt,
4082
+ noPassPrompt,
4083
+ ruleRelation: Number(pv.ruleRelation ?? 0),
4084
+ cptSetList: Array.isArray(pv.cptSetList) ? pv.cptSetList : [],
4085
+ requestLevel: switches.requestLevel,
4086
+ });
4087
+ if (!showDialog)
4088
+ return result;
4089
+ }
3957
4090
  const baseUrl = (await getSessionContext(env)).baseUrl;
3958
4091
  const referer = `${baseUrl}/sp/workflow/flowpage/create/${context.workflow.id}?workflowId=${context.workflow.id}&isCreate=1`;
3959
4092
  const conf = await requestJson(env, 'GET', FORM_ENDPOINTS.conformanceSet, {
@@ -4218,7 +4351,12 @@ async function commandEditLoad(args, env) {
4218
4351
  const slimFields = [];
4219
4352
  const folderOptionValues = _mainValueMap(currentDetails);
4220
4353
  for (const field of fields) {
4221
- const item = { id: field.id, name: field.name, subFormId: field.subFormId, componentKey: field.componentKey };
4354
+ // 同 createForm:`subFormId` 为空则省略该键(缺键=主表字段)。
4355
+ const slimSubFormId = String(field.subFormId ?? '');
4356
+ const item = { id: field.id, name: field.name };
4357
+ if (slimSubFormId)
4358
+ item.subFormId = slimSubFormId;
4359
+ item.componentKey = field.componentKey;
4222
4360
  if (field.required)
4223
4361
  item.required = true;
4224
4362
  for (const key of ['isBrowser', 'isSelect', 'isMultiple', 'browserType', 'browserModule']) {
@@ -4272,12 +4410,6 @@ async function commandEditLoad(args, env) {
4272
4410
  row.rowId = detail.rowId;
4273
4411
  currentValues.push(row);
4274
4412
  }
4275
- // 可写字段补偿索引(同 createForm,见 encodeWritableFieldsCompact):只在 `fields`
4276
- // 被整体移出 stdout 时随输出。只保留确实在本次字段清单里的 id,保证每行都有真实字段名。
4277
- const slimFieldIdSet = new Set(slimFields.map((f) => String(f.id ?? '')));
4278
- const editWritableIds = [...new Set((context.editableIds || []).map((x) => String(x)))]
4279
- .filter((id) => slimFieldIdSet.has(id));
4280
- const writableFieldsCompact = encodeWritableFieldsCompact(editWritableIds, slimFields, SYSTEM_FIELD_KEYS);
4281
4413
  return workflowSuccess('workflow.editLoad', await compactFormOutput({
4282
4414
  status: 'EDIT_READY',
4283
4415
  contextId: token,
@@ -4289,7 +4421,7 @@ async function commandEditLoad(args, env) {
4289
4421
  currentValues,
4290
4422
  fields: slimFields,
4291
4423
  continuation: token,
4292
- }, env, 'last_edit_load.json', writableFieldsCompact));
4424
+ }, env, 'last_edit_load.json'));
4293
4425
  }
4294
4426
  function buildEditLinkageParam(context, dataList, runDataList) {
4295
4427
  const runtime = context.runtime || {};
@@ -4301,7 +4433,7 @@ function buildEditLinkageParam(context, dataList, runDataList) {
4301
4433
  employeeId: context.employee_id || runtime.employee_id || '',
4302
4434
  customParam: {
4303
4435
  permissionType: Number(frame.permission_type ?? 1),
4304
- requestId: context.request_id,
4436
+ requestId: context.requestId ?? context.request_id,
4305
4437
  workflowId: context.workflow.id,
4306
4438
  nodeId: runtime.node_id || '',
4307
4439
  dataSrc: 'NEW',
@@ -4416,7 +4548,7 @@ function buildEditSaveBody(context, prepared) {
4416
4548
  module: 'workflow',
4417
4549
  customParam: {
4418
4550
  isCreate: false,
4419
- requestId: context.request_id,
4551
+ requestId: context.requestId ?? context.request_id,
4420
4552
  isTest: false,
4421
4553
  secretLevel: '0',
4422
4554
  secretLevelValidity: '',
@@ -4496,12 +4628,12 @@ async function commandEditSave(args, env) {
4496
4628
  // saveFormData 已返回成功:阶段推进到 committed。若进程在"回写成功结果"之前被中止,
4497
4629
  // 重放可据此报幂等成功(ALREADY_APPLIED + requestId),而不是只报一句"已使用"让人反查。
4498
4630
  await env.continuation
4499
- .markStage(token, 'committed', { requestId: context.request_id })
4631
+ .markStage(token, 'committed', { requestId: context.requestId ?? context.request_id })
4500
4632
  .catch(() => undefined);
4501
4633
  return workflowSuccess('workflow.editSave', {
4502
4634
  status: 'EDIT_SAVED',
4503
4635
  contextId: token,
4504
- requestId: context.request_id,
4636
+ requestId: context.requestId ?? context.request_id,
4505
4637
  workflow: context.workflow,
4506
4638
  message: '流程表单内容已保存(仅保存表单字段,未提交流程)。',
4507
4639
  saved: { data_id: String(saveData.id ?? ''), updateTimeStamp: newTimeStamp, recordId: String(saveData.recordId ?? '') },
@@ -4547,7 +4679,7 @@ const workflowCreateForm = {
4547
4679
  resource: 'workflow',
4548
4680
  method: 'createForm',
4549
4681
  action: 'workflow.createForm',
4550
- summary: '按工作流参数解析可发起的工作流并载入发起表单:loadBase/loadFrame/loadMinor/formLayout 合并为 runtime 与 frame,产出 context 句柄 + 字段清单 + fileUploadParams + 该工作流的发起偏好(不落业务数据)',
4682
+ summary: '按工作流参数解析可发起的工作流并载入发起表单:loadBase/loadFrame/loadMinor/formLayout 合并为 runtime 与 frame,产出 context 句柄 + 字段清单 + fileUploadParams + 该工作流的发起偏好 + 本表单必填字段的历史参考值(不落业务数据)',
4551
4683
  capability: 'create',
4552
4684
  risk: 'read',
4553
4685
  requiresConfirmation: false,
@@ -4555,7 +4687,7 @@ const workflowCreateForm = {
4555
4687
  workflow: {
4556
4688
  type: 'array',
4557
4689
  items: { type: 'string' },
4558
- description: '工作流条件:支持 "名称(ID)"、名称(可传多个,用英文逗号分隔,如 "报销,报销流程")、纯数字 ID;按当前用户**可发起**的工作流模糊匹配;0 命中或多命中且无全名精确匹配时返回澄清(候选见 candidates),确认后用「名称(ID)」重发',
4690
+ description: '工作流条件:支持 "名称(ID)"、名称(可传多个,用英文逗号分隔,如 "报销,报销流程")、纯数字 ID;按当前用户**可发起**的工作流模糊匹配;0 命中或多命中且无全名精确匹配时返回澄清(候选见 candidates),确认后由你按「名称(ID)」重发(只问用户完整名称或其它可用于检索的关键词,不要向用户索取 ID)',
4559
4691
  },
4560
4692
  relatesTo: { type: 'string', description: '可选关联对象 id(仅透传,不影响表单初始化)' },
4561
4693
  }, { required: ['workflow'] }),