weaver-work-cli 0.1.25 → 0.1.26

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 (81) hide show
  1. package/README.md +30 -30
  2. package/dist/cmd/root.js +1 -0
  3. package/dist/core/runtime-agent.js +19 -0
  4. package/dist/internal/e10/auth/commands.js +47 -27
  5. package/dist/internal/e10/auth/xiaoe.js +62 -43
  6. package/dist/internal/e10/context.js +5 -27
  7. package/dist/shortcuts/archive-oa/constants.js +66 -0
  8. package/dist/shortcuts/archive-oa/continuation.js +70 -0
  9. package/dist/shortcuts/archive-oa/errors.js +53 -0
  10. package/dist/shortcuts/archive-oa/host.js +157 -0
  11. package/dist/shortcuts/archive-oa/index.js +225 -0
  12. package/dist/shortcuts/archive-oa/manifest.js +62 -0
  13. package/dist/shortcuts/archive-oa/operations/borrow-car.js +159 -0
  14. package/dist/shortcuts/archive-oa/operations/borrow-url.js +108 -0
  15. package/dist/shortcuts/archive-oa/operations/confirm.js +48 -0
  16. package/dist/shortcuts/archive-oa/operations/contract.js +120 -0
  17. package/dist/shortcuts/archive-oa/operations/locate.js +50 -0
  18. package/dist/shortcuts/archive-oa/operations/registry.js +40 -0
  19. package/dist/shortcuts/archive-oa/operations/shared.js +181 -0
  20. package/dist/shortcuts/archive-oa/operations/types.js +45 -0
  21. package/dist/shortcuts/archive-oa/operations.js +13 -0
  22. package/dist/shortcuts/index.js +2 -0
  23. package/dist/shortcuts/invoice/host.js +2 -1
  24. package/dist/shortcuts/invoice/manifest.js +111 -0
  25. package/dist/shortcuts/invoice/operations/issuing-qys.js +361 -0
  26. package/dist/shortcuts/invoice/operations/issuing.js +5 -5
  27. package/dist/shortcuts/invoice/operations/registry.js +4 -0
  28. package/dist/shortcuts/workflow/endpoints.js +11 -0
  29. package/dist/shortcuts/workflow/host.js +2 -2
  30. package/dist/shortcuts/workflow/index.js +6 -36
  31. package/dist/shortcuts/workflow/operations/chatscene.js +270 -0
  32. package/dist/shortcuts/workflow/operations/form.js +899 -158
  33. package/dist/shortcuts/workflow/operations/read.js +111 -6
  34. package/dist/shortcuts/workflow/operations/sign-input.js +106 -0
  35. package/dist/shortcuts/workflow/operations/write.js +9 -1
  36. package/dist/shortcuts/workflow/stdout-compact.js +9 -1
  37. package/docs/archive-oa.md +79 -0
  38. package/docs/e10-auth.md +7 -6
  39. package/package.json +1 -1
  40. package/skill-template/business-info.json +16 -0
  41. package/skill-template/domains/archive-oa.md +19 -0
  42. package/skill-template/domains/yepiaotong.md +2 -2
  43. package/skills/weaver-e10-archive-oa/SKILL.md +159 -0
  44. package/skills/weaver-e10-archive-oa/references/archive-locate.md +49 -0
  45. package/skills/weaver-e10-archive-oa/references/borrow-car.md +76 -0
  46. package/skills/weaver-e10-archive-oa/references/borrow-flow.md +53 -0
  47. package/skills/weaver-e10-archive-oa/references/contract-search.md +63 -0
  48. package/skills/weaver-e10-archive-oa/references/disabled-capabilities.md +29 -0
  49. package/skills/weaver-e10-archive-oa/references/qys-contract-search.md +47 -0
  50. package/skills/weaver-e10-archive-oa/references/source-manifest.json +400 -0
  51. package/skills/weaver-e10-builder/SKILL.md +22 -0
  52. package/skills/weaver-e10-caizhilian/SKILL.md +37 -15
  53. package/skills/weaver-e10-caizhilian/references/agent-entry.md +7 -3
  54. package/skills/weaver-e10-caizhilian/references/collaboration.md +5 -3
  55. package/skills/weaver-e10-caizhilian/references/document.md +33 -11
  56. package/skills/weaver-e10-caizhilian/references/file-collection.md +4 -3
  57. package/skills/weaver-e10-caizhilian/references/file-upload.md +2 -3
  58. package/skills/weaver-e10-caizhilian/references/files.md +9 -3
  59. package/skills/weaver-e10-caizhilian/references/folder.md +48 -16
  60. package/skills/weaver-e10-caizhilian/references/setting-attach.md +13 -39
  61. package/skills/weaver-e10-caizhilian/references/setting-backup.md +13 -51
  62. package/skills/weaver-e10-caizhilian/references/setting-encrypt.md +20 -51
  63. package/skills/weaver-e10-caizhilian/references/setting-storage.md +82 -147
  64. package/skills/weaver-e10-caizhilian/references/withheld.md +20 -44
  65. package/skills/weaver-e10-shared/SKILL.md +22 -20
  66. package/skills/weaver-e10-shared/references/e10-auth-and-session.md +14 -15
  67. package/skills/weaver-e10-shared/references/non-interactive-environment.md +10 -10
  68. package/skills/weaver-e10-workflow/references/source-manifest.json +32 -22
  69. package/skills/weaver-e10-workflow/references/workflow-create.md +68 -28
  70. package/skills/weaver-e10-yepiaotong/SKILL.md +10 -7
  71. package/skills/weaver-e10-yepiaotong/references/invoice-issuing-examples.md +49 -1
  72. package/skills/weaver-e10-yepiaotong/references/invoice-issuing-qys-ledger.md +66 -0
  73. package/skills/weaver-e10-yepiaotong/references/invoice-reim.md +59 -36
  74. package/skills/weaver-e10-yepiaotong/references/reim-api-reference.md +54 -509
  75. package/skills/weaver-e10-yepiaotong/references/reim-examples.md +182 -345
  76. package/skills/weaver-e10-yepiaotong/references/reim-expense-subject-rules.md +4 -5
  77. package/skills/weaver-e10-yepiaotong/references/reim-form-fill-rules.md +153 -345
  78. package/skills/weaver-e10-yepiaotong/references/reim-phone-subsidy-rules.md +5 -5
  79. package/skills/weaver-e10-yepiaotong/references/reim-workflow.md +182 -154
  80. package/skills/weaver-e10-yepiaotong/references/source-manifest.json +96 -0
  81. package/skills/weaver-e10-yepiaotong/references/reim-fast-path.md +0 -119
@@ -20,11 +20,13 @@ import { workflowSuccess, schemaOf, SCHEMA_REQUEST_ID, SCHEMA_CONTINUATION, SCHE
20
20
  import { asBool, responseData, toArray, withLeadingMeta, stdoutBytesOf, stdoutCharsOf, 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
+ import { resolveSignRemark } from './sign-input.js';
23
24
  import { compactStdoutPaths } from '../stdout-compact.js';
24
25
  import { continuationReplayError } from '../continuation.js';
25
26
  import { writeStateFileBestEffort } from '../state.js';
26
- import { buildHistoryRequestData, fetchHistoryListSnapshot, filterHistoryRequestData } from './read.js';
27
+ import { buildHistoryRequestData, fetchHistoryListSnapshot, filterHistoryRequestData, pickUniqueMineWorkflow } from './read.js';
27
28
  import { hasXiaoEEnv } from '../../../internal/e10/auth/xiaoe.js';
29
+ import { buildChatSceneInput, formatLocalNow, requestFormFill } from './chatscene.js';
28
30
  // ---------------------------------------------------------------------------
29
31
  // 常量(与 Python 源逐条对齐)
30
32
  // ---------------------------------------------------------------------------
@@ -1217,6 +1219,86 @@ function collectHiddenMultiContentFieldIds(comps) {
1217
1219
  }
1218
1220
  return hiddenFieldIds;
1219
1221
  }
1222
+ /**
1223
+ * 页面根网格(Sheet1)上**被隐藏的单元格**里的字段(2026-10-20 修复)。
1224
+ *
1225
+ * `pageLayouts[0].config.workbook.Sheet1.cells[]` 里 `cell.tag.attrs.hide === true`
1226
+ * ⇒ 该单元格(`col:row`)在表单上不显示,格子里的字段用户看不到。
1227
+ *
1228
+ * 寻址必须**沿容器链往上走**(与隐藏行 / 隐藏列 / 多内容格同一套):组件的 `config.excel.col`/`row`
1229
+ * 是**相对其父容器**的坐标,只有 `excel.parent === 'ROOT'` 那一层才是 Sheet1 的行列号。
1230
+ * 真机反例(总部费用报销,「报销人直接上级」`fieldId 100003720000076596`):字段自身的坐标是
1231
+ * `col:0,row:0`(相对它所在的 `MultiContent` 容器),真正被藏起来的是 Sheet1 上 `col:3,row:4`
1232
+ * 那个格子里的**容器本体**——只比字段自己的坐标就永远不可能命中,值于是漏进「已填内容」。
1233
+ *
1234
+ * 之前这里的实现只拿 comp 自己的 `excel.col:row` 与 Sheet1 的隐藏格直接比对,组合控件(尤其是
1235
+ * `MultiContent`)内部的字段一概漏判;改成沿链上溯后,容器被藏 ⇒ 它内部所有字段一并判隐藏。
1236
+ *
1237
+ * 查不到 `cells` / 没有隐藏格时**不判隐藏**——fail-open,与其它隐藏口径同一降级原则。
1238
+ */
1239
+ function collectHiddenSheetCellFieldIds(page0, comps) {
1240
+ const hiddenFieldIds = new Set();
1241
+ const sheet1 = page0?.config?.workbook?.Sheet1;
1242
+ const cells = sheet1 && Array.isArray(sheet1.cells) ? sheet1.cells : [];
1243
+ if (!cells.length)
1244
+ return hiddenFieldIds;
1245
+ /** `col:row`(两边同口径归一成数字,避免 `"3"` 与 `3` 对不上);坐标不可解析时返回 null。 */
1246
+ const posOf = (col, row) => {
1247
+ const c = Number(col);
1248
+ const r = Number(row);
1249
+ if (!Number.isFinite(c) || !Number.isFinite(r))
1250
+ return null;
1251
+ return `${c}:${r}`;
1252
+ };
1253
+ const hidePositions = new Set();
1254
+ for (const cell of cells) {
1255
+ if (!cell || typeof cell !== 'object')
1256
+ continue;
1257
+ const attrs = cell.tag?.attrs;
1258
+ if (!attrs || typeof attrs !== 'object' || !attrs.hide)
1259
+ continue;
1260
+ const pos = posOf(cell.col, cell.row);
1261
+ if (pos)
1262
+ hidePositions.add(pos);
1263
+ }
1264
+ if (!hidePositions.size)
1265
+ return hiddenFieldIds;
1266
+ // nodeId → 组件:组件的 `excel.parent` 指向父容器的 nodeId,靠它才能找回父容器自己的坐标。
1267
+ const compByNodeId = new Map();
1268
+ for (const comp of toArray(comps)) {
1269
+ if (!comp || typeof comp !== 'object')
1270
+ continue;
1271
+ const nodeId = String(comp.config?.excel?.nodeId ?? '').trim();
1272
+ if (nodeId)
1273
+ compByNodeId.set(nodeId, comp);
1274
+ }
1275
+ for (const comp of toArray(comps)) {
1276
+ if (!comp || typeof comp !== 'object')
1277
+ continue;
1278
+ const fieldId = String(comp.config?.fieldId ?? '').trim();
1279
+ if (!fieldId)
1280
+ continue;
1281
+ let cursor = comp;
1282
+ while (cursor) {
1283
+ const excel = cursor.config?.excel;
1284
+ if (!excel || typeof excel !== 'object')
1285
+ break;
1286
+ const parentId = String(excel.parent ?? '').trim();
1287
+ if (!parentId || parentId === 'ROOT') {
1288
+ // 到根网格这一层才是 Sheet1 的真实行列号(容器内的坐标是相对的,比不得)。
1289
+ const pos = posOf(excel.col, excel.row);
1290
+ if (pos && hidePositions.has(pos))
1291
+ hiddenFieldIds.add(fieldId);
1292
+ break;
1293
+ }
1294
+ const owner = compByNodeId.get(parentId);
1295
+ if (!owner)
1296
+ break;
1297
+ cursor = owner;
1298
+ }
1299
+ }
1300
+ return hiddenFieldIds;
1301
+ }
1220
1302
  function parseLayoutMeta(layoutData) {
1221
1303
  const hiddenCellIds = new Set();
1222
1304
  const compsFieldIds = new Set();
@@ -1226,44 +1308,6 @@ function parseLayoutMeta(layoutData) {
1226
1308
  if (Array.isArray(pageLayouts) && pageLayouts.length)
1227
1309
  page0 = pageLayouts[0];
1228
1310
  if (page0 && typeof page0 === 'object') {
1229
- const wb = page0.config && page0.config.workbook ? page0.config.workbook : {};
1230
- const sheet1 = wb.Sheet1;
1231
- const cells = sheet1 ? sheet1.cells : null;
1232
- const hidePositions = new Set();
1233
- if (Array.isArray(cells)) {
1234
- for (const cell of cells) {
1235
- if (!cell || typeof cell !== 'object')
1236
- continue;
1237
- const attrs = cell.tag && cell.tag.attrs ? cell.tag.attrs : {};
1238
- if (attrs.hide) {
1239
- try {
1240
- hidePositions.add(`${cell.col}:${cell.row}`);
1241
- }
1242
- catch {
1243
- /* ignore */
1244
- }
1245
- }
1246
- }
1247
- }
1248
- if (hidePositions.size) {
1249
- for (const comp of page0.comps || []) {
1250
- if (!comp || typeof comp !== 'object')
1251
- continue;
1252
- const cfg = comp.config || {};
1253
- const fid = String(cfg.fieldId ?? '').trim();
1254
- if (!fid)
1255
- continue;
1256
- const excel = cfg.excel || {};
1257
- try {
1258
- const pos = `${excel.col}:${excel.row}`;
1259
- if (hidePositions.has(pos))
1260
- hiddenCellIds.add(fid);
1261
- }
1262
- catch {
1263
- /* ignore */
1264
- }
1265
- }
1266
- }
1267
1311
  for (const comp of page0.comps || []) {
1268
1312
  if (!comp || typeof comp !== 'object')
1269
1313
  continue;
@@ -1280,6 +1324,9 @@ function parseLayoutMeta(layoutData) {
1280
1324
  // 行隐藏 / 单元格隐藏 / 明细表隐藏列 / 多内容单元格隐藏**同义**(都是"用户在表单上看不到"),
1281
1325
  // 并成一份 `hiddenCellIds`:下游 `visibleFields`/`initialValue`/`missingRequiredFields`/「已填内容」
1282
1326
  // /联动提示全部只认这一份,且随句柄以 `hiddenCellFieldIds` 传给 `createRequest`,两边口径天然一致。
1327
+ // 单元格隐藏**必须沿容器链上溯**(2026-10-20 修复):容器的 `excel` 坐标才是 Sheet1 上的位置。
1328
+ for (const fid of collectHiddenSheetCellFieldIds(page0, page0.comps))
1329
+ hiddenCellIds.add(fid);
1283
1330
  for (const fid of collectHiddenRowFieldIds(page0, page0.comps))
1284
1331
  hiddenCellIds.add(fid);
1285
1332
  for (const fid of collectHiddenDetailColumnFieldIds(page0, page0.comps))
@@ -1349,17 +1396,29 @@ function requiredInfoWarningText(context) {
1349
1396
  // 发起侧工作流解析(原 createList 的职责;候选池 = 当前用户「可发起的工作流」)
1350
1397
  // ---------------------------------------------------------------------------
1351
1398
  /**
1352
- * 把一个「纯名称」切成多个匹配关键词(2026-09-23 用户裁定)。
1399
+ * 名称关键词的**填充词**(按长词优先依次脱壳)。
1400
+ *
1401
+ * 用户裁定:「去掉一些词:工作流、流程」(2026-09-23);「现在要**额外去掉申请**关键字」
1402
+ * (2026-10-21)。顺序有讲究:`工作流程` 必须排在 `工作流` / `流程` 之前
1403
+ * (`工作流` 与 `流程` 在 `流` 处重叠,先剥短的会漏判)。
1404
+ */
1405
+ const CREATE_KEYWORD_FILLERS = ['工作流程', '工作流', '流程', '申请'];
1406
+ /**
1407
+ * 把一个「纯名称」切成多个名称关键词(2026-09-23 用户裁定 + 2026-10-21 追加净词)。
1353
1408
  *
1354
1409
  * 单个格内可用逗号分隔多个关键词(中英文逗号都认),也可用数组多元素表达;命中结果做**并集**,
1355
- * 目的是**提高候选召回** —— 用户首次要发起某个流程时,多给几个说法更容易命中他真正要发起的那个,
1356
- * 从而少让用户回来补全名称。
1357
- *
1358
- * 去词规则(用户裁定,逐字):「**去掉一些词:工作流、流程**」。落到实现上是——
1359
- * **只丢弃"整格就是填充词"的关键词**(`流程` / `工作流` / `工作流程`),其余**原样保留**:
1360
- * - `"报销,报销流程,流程"` → 关键词 = `报销`、`报销流程`(**两个**,`流程` 被丢掉);
1361
- * - `报销流程` **不在词内剥离** —— 它是有效关键词(剥成 `报销` 会让"多关键词"退化成单关键词)。
1362
- * 匹配不到的关键词在并集里自然贡献为空,无害,所以不必额外清理。
1410
+ * 目的是**提高候选召回** —— 用户首次要发起某个流程时,多给几个说法更容易命中他真正要发起的那个。
1411
+ *
1412
+ * 每个关键词带**两副面孔**(2026-10-21 用户裁定):
1413
+ * - `raw` = 原样(`请假流程`、`请假申请`);
1414
+ * - `core` = 去掉填充词后的净词(`请假流程` → `请假`、`请假申请` → `请假`、`总部请假` → `总部请假`)。
1415
+ *
1416
+ * 两者**都参与本地模糊过滤**(候选取并集),于是「请假申请」既按它自己、也按「请假」去找,
1417
+ * 能命中「请假申请单」这类名字;净词与原词相同的词天然只算一次。
1418
+ *
1419
+ * **只丢弃"整格全是填充词"的关键词**(`流程` / `工作流` / `申请` …):它们不代表任何业务含义,
1420
+ * 留着会命中一大批无关流程。`报销申请` 这类**含填充词但本身有效**的词不会被丢 ——
1421
+ * 它的净词 `报销` 才是干活的那个。
1363
1422
  */
1364
1423
  function splitCreateKeywords(raw) {
1365
1424
  const out = [];
@@ -1368,15 +1427,18 @@ function splitCreateKeywords(raw) {
1368
1427
  const token = piece.trim();
1369
1428
  if (!token)
1370
1429
  continue;
1371
- // 填充词按**长词优先**脱壳:`工作流程` → 空(`工作流`+`流程` 在 `流` 处重叠,顺序错会漏判)
1372
- const residue = token.split('工作流程').join('').split('工作流').join('').split('流程').join('');
1373
- if (!residue.trim())
1430
+ let core = token;
1431
+ for (const filler of CREATE_KEYWORD_FILLERS)
1432
+ core = core.split(filler).join('');
1433
+ core = core.trim();
1434
+ // 整格都是填充词 ⇒ 丢弃(留着会让 `流程` 命中「请假流程」「合同流程」等一大批)
1435
+ if (!core)
1374
1436
  continue;
1375
1437
  const key = token.toLowerCase();
1376
1438
  if (seen.has(key))
1377
1439
  continue;
1378
1440
  seen.add(key);
1379
- out.push(token);
1441
+ out.push({ raw: token, core });
1380
1442
  }
1381
1443
  return out;
1382
1444
  }
@@ -1390,10 +1452,17 @@ function splitCreateKeywords(raw) {
1390
1452
  * - **0 命中 → 澄清**:不能把「没听懂你的条件」伪装成「没有这条流程」,否则模型会如实转述成
1391
1453
  * "没有该工作流",用户拿不到可操作的信息;
1392
1454
  * - 1 命中 → 直接用;
1393
- * - 多命中但候选中**恰有一个与所写名称完全一致** → 直接用,不为"要不要多打几个字"再问一次;
1394
- * - 多命中且没有完全一致的 → 澄清并列出候选(`display` 即 `名称(ID)`,用户选定后原样回传)。
1455
+ * - 多命中时,**全名一致必须唯一**才直接用(2026-10-21 用户二次裁定,撤销同日"按词序先到先得"):
1456
+ * 连"净词 + 原词"一起算,若与候选全名一致的说法只指向**同一个**候选 ⇒ 用它;若指向**多个不同**
1457
+ * 候选(如 `费用报销,报销流程审批` 恰好各自对上一条)⇒ 这是用户给的条件本身有歧义,仍回来澄清;
1458
+ * - 多命中且没有任何全名一致的 → 澄清并列出候选(`display` 即 `名称(ID)`,用户选定后原样回传)。
1459
+ * 这一档在澄清**之前**先做一次消歧(2026-10-20 用户裁定):把候选拿去查「我发起的」工作流树,
1460
+ * 用户近一年只发起过其中**一个**就直接用它(`from:'mineRecentUnique'`);否则/失败则照旧澄清。
1461
+ * **只有这一档会消歧**:0 命中、以及"只给了填充词"那两档都不查(前者本就没候选可比,
1462
+ * 后者在发任何远端请求前就澄清了)。
1395
1463
  *
1396
- * 名称格支持**多关键词**(`"报销,报销流程"`,见 `splitCreateKeywords`):各关键词命中做并集去重后,
1464
+ * 名称格支持**多关键词**(`"报销,报销流程"`,见 `splitCreateKeywords`):每个关键词同时贡献
1465
+ * **净词**与**原词**(`请假申请` → `请假` + `请假申请`),两组词各自命中后**并集去重**,
1397
1466
  * 再走上面同一套判据。**只发一次** `getNewFlowListData` 而不是每个关键词并行各发一次 —— 该接口
1398
1467
  * 请求体固定为 `{wfpBelongTypeCode, loadOfsFlow, clientCtrl}`、**没有任何关键词入参**(V11.3 Python
1399
1468
  * 基线 `e10_workflow_create.py:395-436` 同样是取全量后本地过滤;真机单笔 ≈1,649 ms / 228,929 B),
@@ -1416,8 +1485,17 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1416
1485
  // 一个关键词都没解析出来(如只传了 `流程`)时**连可发起列表都不去拉**
1417
1486
  // (该接口真机单笔 ≈1,649 ms / 228,929 B),与"完全不传 workflow"同一口径。
1418
1487
  const explicitIds = new Set();
1419
- const nameTerms = [];
1420
- const keywordSeen = new Set();
1488
+ /** 参与**本地模糊过滤**的词:每个关键词先净词、再原词(同一词只留一个)。 */
1489
+ const filterTerms = [];
1490
+ /**
1491
+ * 参与**全名精确匹配**的词集合:每个关键词先净词、再原词(如 `请假申请` → `请假` + `请假申请`)。
1492
+ *
1493
+ * 只做集合比对、**不看先后次序**(2026-10-21 二次裁定:全名一致必须唯一才直接用,不允许按词序
1494
+ * 先到先得)。
1495
+ */
1496
+ const exactTerms = [];
1497
+ const filterSeen = new Set();
1498
+ const exactSeen = new Set();
1421
1499
  for (const term of rawTerms) {
1422
1500
  const parsed = parseNameWithId(term);
1423
1501
  // 纯数字同样按 ID 处理(`parseNameWithId` 只认 "名称(数字)" 形态)
@@ -1427,14 +1505,29 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1427
1505
  continue;
1428
1506
  }
1429
1507
  for (const keyword of splitCreateKeywords(parsed.name)) {
1430
- const key = keyword.toLowerCase();
1431
- if (keywordSeen.has(key))
1432
- continue;
1433
- keywordSeen.add(key);
1434
- nameTerms.push(keyword);
1508
+ const coreKey = keyword.core.toLowerCase();
1509
+ const rawKey = keyword.raw.toLowerCase();
1510
+ // 模糊过滤:**单字净词不参与**(`申请单` 剥成 `单` 会把整张清单拉进来);
1511
+ // 原词永远参与,所以"只说得出一个怪名字"时召回不受影响。
1512
+ if (keyword.core.length >= 2 && !filterSeen.has(coreKey)) {
1513
+ filterSeen.add(coreKey);
1514
+ filterTerms.push(keyword.core);
1515
+ }
1516
+ if (!filterSeen.has(rawKey)) {
1517
+ filterSeen.add(rawKey);
1518
+ filterTerms.push(keyword.raw);
1519
+ }
1520
+ if (!exactSeen.has(coreKey)) {
1521
+ exactSeen.add(coreKey);
1522
+ exactTerms.push(keyword.core);
1523
+ }
1524
+ if (!exactSeen.has(rawKey)) {
1525
+ exactSeen.add(rawKey);
1526
+ exactTerms.push(keyword.raw);
1527
+ }
1435
1528
  }
1436
1529
  }
1437
- if (!explicitIds.size && !nameTerms.length) {
1530
+ if (!explicitIds.size && !filterTerms.length) {
1438
1531
  throw workflowClarificationError('workflow', queryText, '要发起哪个流程?请提供该流程的完整名称(或其它可用于检索的关键词)后再继续。');
1439
1532
  }
1440
1533
  const response = await requestJson(env, 'POST', FORM_ENDPOINTS.workflowCreateList, AJAX_QUERY, {
@@ -1455,7 +1548,7 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1455
1548
  }
1456
1549
  }
1457
1550
  // 名称关键词:逐个取命中,**并集去重**(多给几个说法只扩大候选面,不改变下面的命中判据)
1458
- for (const keyword of nameTerms) {
1551
+ for (const keyword of filterTerms) {
1459
1552
  for (const item of simplifyWorkflows(data, keyword)) {
1460
1553
  if (seen.has(String(item.id)))
1461
1554
  continue;
@@ -1470,13 +1563,30 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1470
1563
  return { id, name: String(only.workflowname ?? ''), source: explicitIds.has(id) ? 'workflowId' : 'single' };
1471
1564
  }
1472
1565
  if (picked.length > 1) {
1473
- const wanted = new Set(nameTerms.map((t) => t.trim().toLowerCase()).filter(Boolean));
1566
+ // ★ 2026-10-21 用户二次裁定:**退回「全名命中必须唯一」**——同日早先那版"按词序循环、第一个
1567
+ // 全名一致的就用"已撤销。理由:多个说法各自全名命中**不同**候选时,用户给的条件本身就有歧义
1568
+ // (两条互相竞争的精确证据),按词序替用户先到先得等于擅自替他裁决,宁可回来问一句。
1569
+ //
1570
+ // 仍算"唯一"、可直接用的两档:
1571
+ // - 只有一个候选与某个说法全名一致,其余说法只是陪跑(命中的是别的、或没命中);
1572
+ // - 多个说法全名命中的是**同一个**候选(去重后仍是一条)。
1573
+ // "说法"包含每个关键词的**净词**与**原词**(见 `exactTerms`),两者都参与比对。
1574
+ //
1575
+ // 某个说法命中**多条同名候选**(不同 id、名字一字不差)同样算不唯一 ⇒ 一并澄清。
1576
+ const wanted = new Set(exactTerms.map((t) => t.trim().toLowerCase()).filter(Boolean));
1474
1577
  const exact = picked.filter((item) => wanted.has(String(item.workflowname ?? '').trim().toLowerCase()));
1475
- // 多命中时有唯一全名精确匹配 → 直接选中(与查询侧 exact_candidates 优选同一判据)
1476
1578
  if (exact.length === 1) {
1477
1579
  return { id: String(exact[0].id), name: String(exact[0].workflowname ?? ''), source: 'exactName' };
1478
1580
  }
1581
+ // 澄清候选:能对上的那几条优先(比全量 picked 精确),一条都对不上才退回全量。
1479
1582
  const list = exact.length ? exact : picked;
1583
+ // ★ 2026-10-20 用户裁定:澄清前先按「用户近一年实际发起过哪些」消歧。
1584
+ // 用户只发起过其中某一个 ⇒ 直接用,不再问一遍(`from:mineRecentUnique` 标明这是推断的,
1585
+ // 不是用户明说的)。查不到 / 不唯一 / 请求失败 ⇒ 一律回落下面原样的多候选澄清。
1586
+ const inferred = await pickUniqueMineWorkflow(list.map((item) => ({ id: String(item.id), name: String(item.workflowname ?? '') })), env);
1587
+ if (inferred) {
1588
+ return { id: inferred.id, name: inferred.name, source: 'single', from: 'mineRecentUnique' };
1589
+ }
1480
1590
  // `clarificationQuestion` 是**用户口径**:它会被 `note` 要求「原样交给用户确认」,因此
1481
1591
  // 只能写"发生了什么 + 要用户做什么"。**禁止**把本机判定依据(如"其中没有与所写名称完全
1482
1592
  // 一致的")写进这句——那属于 CLI 内部实现,外传会让用户在确认卡片上看到与己无关的判据
@@ -1496,9 +1606,9 @@ async function resolveCreateWorkflow(rawWorkflow, env) {
1496
1606
  *
1497
1607
  * 背景(2026-09-23 实测 + 用户裁定):`POST …memoryPrompt` 带 `workflowId` 时返回的是**全集** ——
1498
1608
  * `--- user.md ---` / `--- memory.md ---`(全局画像 + 全局别名映射)+ `--- memory_<workflowId>.md ---`
1499
- * (该工作流维度)。于是 `launchPreferences` 与入口处读过的无参 `workflow.memoryPrompt`(纯全局)
1609
+ * (该工作流维度)。于是这条命令取回的记忆与入口处读过的无参 `workflow.memoryPrompt`(纯全局)
1500
1610
  * **内容重叠**:真机实测同一租户 `promptText` 2,467 字符 = 全局 543 + 工作流维度 1,924,
1501
- * 同一段全局记忆会在上下文里出现两次。而 `launchPreferences` 的语义是「**该工作流的**发起偏好」,
1611
+ * 同一段全局记忆会在上下文里出现两次。而这里要的是「**该工作流的**发起偏好」,
1502
1612
  * 全局那份已由入口读取覆盖,不应重复下发。
1503
1613
  *
1504
1614
  * 故只保留标记 `--- memory_<workflowId>.md ---` 及其**之后**的内容:
@@ -1521,16 +1631,17 @@ function extractWorkflowMemorySection(promptText, workflowId) {
1521
1631
  return text.slice(at).trim();
1522
1632
  }
1523
1633
  /**
1524
- * 顺带取回用户在这条工作流上的发起偏好(`buildAgentMemoryPrompt`,带 `workflowId`)。
1634
+ * 取回用户在这条工作流上的记忆(`buildAgentMemoryPrompt`,带 `workflowId`)。
1525
1635
  *
1526
- * `promptText` **只含该工作流维度的记忆**(全局画像/全局别名由入口的无参 `workflow.memoryPrompt`
1527
- * 覆盖,这里经 `extractWorkflowMemorySection` 裁掉,避免同一段全局记忆在上下文里出现两次)。
1636
+ * 返回值**只含该工作流维度的那一段**(全局画像 / 全局别名由入口的无参
1637
+ * `workflow.memoryPrompt` 覆盖,这里经 `extractWorkflowMemorySection` 裁掉,
1638
+ * 避免同一段全局记忆在上下文里出现两次)。
1528
1639
  *
1529
- * best-effort:读记忆失败(未开通记忆 / 接口不可用)不是错误——一律按新用户处理,
1530
- * 只体现在 `launchPreferences.available` 上,绝不阻断表单载入(与 `workflow.memoryPrompt` 同口径)。
1640
+ * best-effort:读不到(没开通记忆 / 404 / 接口故障)一律返回空串 —— 对模型而言
1641
+ * 「没有记忆」和「读取失败」的处理完全一样(按新用户处理、不要重试),不需要再分档。
1642
+ * 绝不阻断表单载入(与 `workflow.memoryPrompt` 同口径)。
1531
1643
  */
1532
- async function fetchLaunchPreferences(workflowId, env) {
1533
- const result = { available: false, workflowId, promptText: '' };
1644
+ async function fetchWorkflowMemory(workflowId, env) {
1534
1645
  try {
1535
1646
  const response = await env.host.workflowRequest({
1536
1647
  method: 'POST',
@@ -1540,38 +1651,21 @@ async function fetchLaunchPreferences(workflowId, env) {
1540
1651
  });
1541
1652
  // 该接口原始返回是 {code,msg,status,data:"<markdown>",fail},业务成功标志是 status/fail
1542
1653
  // **不是 success**——这里**不能**用 responseData 解包(会把 data 里的 markdown 当主体,
1543
- // 导致 promptText 恒空)。与 workflow.memoryPrompt 同源。
1544
- // 只认 `data`(字符串)作为偏好正文:**不回退 `msg`**——`msg` 里是「接口返回成功」这类
1545
- // 通用提示,回退它会让"没配记忆"的环境被读成有一条内容是"接口返回成功"的偏好。
1654
+ // 导致正文恒空)。与 workflow.memoryPrompt 同源。
1655
+ // 只认 `data`(字符串)作为正文:**不回退 `msg`**——`msg` 里是「接口返回成功」这类
1656
+ // 通用提示,回退它会让"没配记忆"的环境被读成有一条内容是"接口返回成功"的记忆。
1546
1657
  const body = (response && typeof response === 'object' ? response : {});
1547
- const rawPromptText = typeof body.data === 'string' ? body.data.trim() : '';
1548
- const promptText = extractWorkflowMemorySection(rawPromptText, workflowId);
1549
1658
  const businessOk = body.fail !== true && body.status !== false && body.success !== false;
1550
- result.available = Boolean(businessOk && promptText);
1551
- result.promptText = promptText;
1659
+ if (!businessOk)
1660
+ return '';
1661
+ const rawPromptText = typeof body.data === 'string' ? body.data.trim() : '';
1662
+ return extractWorkflowMemorySection(rawPromptText, workflowId);
1552
1663
  }
1553
- catch (error) {
1554
- // 404 = 该智能体/工作流还没有记忆条目(未开通记忆),属**正常的新用户状态**,不是失败:
1555
- // 保持 `available:false` + `promptText:''`,**不加任何"失败"提示**、也不阻断任务。
1556
- // 其它错误才提示"读取失败、按新用户处理"。
1557
- if (!isNotFoundError(error)) {
1558
- result.note = '该工作流的记忆偏好读取失败,按新用户处理,不要重试。';
1559
- }
1664
+ catch {
1665
+ // 404 = 该工作流还没有记忆条目(未开通记忆)、其它错误 = 接口不可用;
1666
+ // 两者都按「没有记忆」处理,既不加提示也不阻断任务。
1667
+ return '';
1560
1668
  }
1561
- return result;
1562
- }
1563
- /**
1564
- * 是否是「资源不存在」类错误(HTTP 404)。
1565
- *
1566
- * 真机上该错误由 host 统一抛成 `workflowApiError('http_error', 'OA 接口 HTTP 404', {code:404, details:{status:404}})`,
1567
- * 故同时认 `code` 与 `details.status` 两处。不依赖类实例判断,避免把跨包包装过的错误漏掉。
1568
- */
1569
- function isNotFoundError(error) {
1570
- const record = (error && typeof error === 'object') ? error : {};
1571
- if (Number(record.code) === 404)
1572
- return true;
1573
- const details = record.details;
1574
- return Boolean(details && typeof details === 'object' && Number(details.status) === 404);
1575
1669
  }
1576
1670
  // ---------------------------------------------------------------------------
1577
1671
  // createForm:解析工作流 + loadBase/loadFrame/loadMinor/formLayout + 字段输出
@@ -1643,23 +1737,99 @@ function singleExplicitWorkflowId(rawWorkflow) {
1643
1737
  const parsed = parseNameWithId(terms[0]);
1644
1738
  return parsed.id ?? (/^[0-9]+$/.test(terms[0]) ? terms[0] : '');
1645
1739
  }
1646
- /** 表单载入 + 该工作流的发起偏好:两者只共享 `workflowId`,可以并行。 */
1740
+ /** 表单载入 + 该工作流的记忆:两者只共享 `workflowId`,可以并行。 */
1647
1741
  async function loadCreateFormPack(workflowId, env) {
1648
- const [{ data }, launchPreferences] = await Promise.all([
1742
+ const [{ data }, workflowMemory] = await Promise.all([
1649
1743
  buildCreateFormData(workflowId, env),
1650
1744
  // 记忆读取是 best-effort(内部全 catch),不会把表单载入的错误顶掉。
1651
- fetchLaunchPreferences(workflowId, env),
1745
+ fetchWorkflowMemory(workflowId, env),
1652
1746
  ]);
1653
- return { data, launchPreferences };
1747
+ return { data, workflowMemory };
1654
1748
  }
1655
- async function commandCreateForm(args, env) {
1656
- // ★ 2026-10-16 用户裁定:返回值里固定带上**本命令自身的耗时**(毫秒)。
1657
- // 起点取在函数入口(含下面那次会话惰性初始化),终点取在结果产出之后 —— 即"从命令开始执行到
1658
- // 执行完成"。取值不做开关、不进 skill 文档(用户明确要求"直接默认加上")。
1659
- const startedAt = Date.now();
1749
+ /**
1750
+ * 智能填参:把「用户意图 + 表单字段上下文」交给独立智能体(`chatScene`),换回可直接使用的 `data`。
1751
+ *
1752
+ * ## 什么时候调
1753
+ *
1754
+ * 两个条件**同时**成立才调,缺一就原样返回(= 老行为):
1755
+ * 1. 调用方给了 `userQuestion`(非空)—— 空串时智能体没有可依据的意图,接口也回不出有效结果,
1756
+ * 所以仍以它为前置(2026-10-20 用户裁定);
1757
+ * 2. 拿到了 `baseUrl`(真连上了 OA)。
1758
+ *
1759
+ * **不看小e 档**(2026-10-20 用户裁定):以前这一档被跳过,理由是"填单发生在宿主页面、算了也没人用";
1760
+ * 现在照走 —— 走通时 `createForm` 直接把算好的 `data` 交给模型,页面填单同样能用上。
1761
+ *
1762
+ * ## 失败怎么处理
1763
+ *
1764
+ * `requestFormFill` 内部吞掉所有异常并返回 `ok:false`,这里**直接不当回事**:返回
1765
+ * `applied:false`,调用方照旧回 `historyRequestData` / `systemFields`。智能体挂掉不能把
1766
+ * 发起流程打死 —— 这是本功能唯一的硬约束。
1767
+ *
1768
+ * ## 传什么记忆
1769
+ *
1770
+ * `userMemory` 用该工作流的记忆(`workflowMemory`)。它是
1771
+ * **一段 Markdown 文本**,不是键值对象(2026-10-19 用户裁定)。全局画像由入口处的
1772
+ * `workflow.memoryPrompt` 覆盖,这里不重复取。
1773
+ */
1774
+ async function resolveIntentFill(pack, userQuestion, baseUrl, env) {
1775
+ const question = String(userQuestion ?? '').trim();
1776
+ // ★ 2026-10-20 用户裁定(口径修正):前置**只有两条** ——「`userQuestion` 非空」+「连上了 OA」。
1777
+ // `userQuestion` 为空时智能体没有可依据的意图、接口也回不出有效结果,故照旧不调;
1778
+ // **小e 档不再豁免**(此前 `hasXiaoEEnv` 直接关掉这条链路,现已按用户要求去掉)。
1779
+ // 走通与否仍由接口结果决定:回不出可用的 `data.chatMessage.content`(JSON)就 `applied:false`。
1780
+ if (!question || !baseUrl)
1781
+ return { applied: false };
1782
+ const fields = Array.isArray(pack.data.fields) ? pack.data.fields : [];
1783
+ if (!fields.length)
1784
+ return { applied: false };
1785
+ const input = buildChatSceneInput({
1786
+ userQuestion: question,
1787
+ workflow: {
1788
+ id: String(pack.data.workflow?.id ?? ''),
1789
+ name: String(pack.data.workflow?.name ?? ''),
1790
+ },
1791
+ fields,
1792
+ historyRequestData: pack.data.historyRequestData ?? null,
1793
+ systemFields: pack.data.systemFields ?? {},
1794
+ userMemory: String(pack.workflowMemory ?? ''),
1795
+ now: formatLocalNow(),
1796
+ });
1797
+ const outcome = await requestFormFill(env, input);
1798
+ if (!outcome.ok || !outcome.data)
1799
+ return { applied: false };
1800
+ return {
1801
+ applied: true,
1802
+ data: outcome.data,
1803
+ // 智能体的标题规则:有默认值时它只会给空串(沿用默认);给了值才是用户明确要求的标题
1804
+ requestName: String(outcome.systemData?.requestName ?? '').trim(),
1805
+ // 供 `createRequestDirect` 把「紧急程度 / 密级 / 签字意见」一并透传给 `createRequest`;
1806
+ // `createForm` 自己不消费这几个键(返回面一个字不变)。
1807
+ systemData: outcome.systemData ?? {},
1808
+ // ★ 2026-10-19 用户裁定:把「生成表单参数」这段的耗时单独带出去(只在真走通 chatScene 时才有)。
1809
+ // 口径 = `requestFormFill` 内部这一趟(发请求 → 拿回 content → parse → 归一化),
1810
+ // 不含本函数上游的会话初始化与表单取数 —— 那些已由 `durationMs` 计入,两者是「整条命令」与
1811
+ // 「其中智能体这一段」的关系,不是并列的两笔开销。
1812
+ durationMs: outcome.durationMs,
1813
+ };
1814
+ }
1815
+ /**
1816
+ * `createForm` 的**取数阶段**(原 `commandCreateForm` 前半段,逐行搬过来,行为一字不改)。
1817
+ *
1818
+ * 抽出来的唯一目的:`createRequestDirect` 需要「未经压缩、未经落盘」的原始 payload —— 那条命令要把
1819
+ * `contextId` 与智能体算出的 `data` 直接喂给 `createRequest`,而 `commandCreateForm` 的输出可能
1820
+ * 因为超线只剩一句"结果已落盘"的提示,取不到这两个键。
1821
+ */
1822
+ async function prepareCreateFormPayload(args, env) {
1660
1823
  // 会话是**惰性**加载的(`ensureSession` 只在首次调用时填充)。本命令随后会并发发起多个请求,
1661
1824
  // 先在这里显式取一次,避免并发首调各自走一遍会话初始化。
1662
- await env.host.getSession();
1825
+ // 顺带留住返回的上下文:智能填参要用它的 `baseUrl` 判「是不是真连了 OA」。
1826
+ //
1827
+ // 顺带计时:登录取凭据(小e 环境下是 `tokens.py` 换 eteamId + `teamsCheck` 换登录人这一整套)
1828
+ // 走的是 auth 层自己的 fetch,不经 `host.workflowRequest` ⇒ 天然落在 `apiDurations` 之外。
1829
+ // 这一段的耗时单独带出去(`authMs`),否则「总耗时 − 各接口」会凭空多出几百毫秒对不上账。
1830
+ const authStartedAt = Date.now();
1831
+ const sessionCtx = await env.host.getSession();
1832
+ const authMs = Date.now() - authStartedAt;
1663
1833
  let target;
1664
1834
  let pack;
1665
1835
  const explicitId = singleExplicitWorkflowId(args.workflow);
@@ -1691,22 +1861,83 @@ async function commandCreateForm(args, env) {
1691
1861
  // 完整**(pretty JSON,未紧凑),所以模型按提示读回来拿到的是与 stdout 同口径的全量内容。
1692
1862
  //
1693
1863
  // 显式标注为 `Record<string, any>`:`pack.data` 是带索引签名的宽松对象,而对象字面量里又显式写了
1694
- // `workflow` / `launchPreferences`,TS 的推断会**丢掉索引签名**、只留这两个显式键。
1864
+ // `workflow`,TS 的推断会**丢掉索引签名**、只留这个显式键。
1695
1865
  const fullPayload = {
1696
1866
  ...pack.data,
1697
- workflow: { ...(pack.data.workflow || {}), selectionSource: target.source },
1698
- launchPreferences: pack.launchPreferences,
1867
+ workflow: {
1868
+ ...(pack.data.workflow || {}),
1869
+ selectionSource: target.source,
1870
+ // ★ 2026-10-20 用户裁定:`from` **只在**「多候选按近一年发起记录推断出来」这一种场景出现,
1871
+ // 其余场景一个字都不多(模型据此知道这个工作流是推断的,而不是用户明说的)。
1872
+ ...(target.from ? { from: target.from } : {}),
1873
+ },
1874
+ };
1875
+ // 该工作流的记忆:**一段 Markdown 原文**,读不到就不出这个键(模型见到"没有这个键"就按新用户处理)。
1876
+ if (pack.workflowMemory)
1877
+ fullPayload.workflowMemory = pack.workflowMemory;
1878
+ // ★ 2026-10-19 用户裁定:`createForm` 在返回前调 `chatScene`,由独立智能体把用户意图直接算成
1879
+ // `data` 参数。成功时返回面收窄为「记忆(workflowMemory)+ fields + data」:
1880
+ // - `historyRequestData` 删除(历史值已由智能体消化进 data,再发一份只会让模型二选一)
1881
+ // - `systemFields` / `systemFieldWarning` 删除(智能体已按默认值 + 选项约束算过标题与紧急程度)
1882
+ // - `requiredInfoWarning` **保留**:它不是 systemFields 的内容,而是「OA 没下发静态字段权限,
1883
+ // 照常按 missingFields 补齐」这条**行为指引**;删掉会让模型在未下发权限的表单上误判。
1884
+ // 失败(含小e 档、未传 userQuestion)⇒ 一个字都不改,原样走老口径。
1885
+ const fill = await resolveIntentFill(pack, String(args.userQuestion ?? ''), sessionCtx.baseUrl, env);
1886
+ if (fill.applied) {
1887
+ delete fullPayload.historyRequestData;
1888
+ delete fullPayload.systemFields;
1889
+ delete fullPayload.systemFieldWarning;
1890
+ fullPayload.data = fill.data;
1891
+ // 智能体只在「用户明确要求标题」时才给非空值;它给了才覆盖 createForm 的默认标题
1892
+ if (fill.requestName)
1893
+ fullPayload.requestName = fill.requestName;
1894
+ }
1895
+ return { payload: fullPayload, fill, authMs };
1896
+ }
1897
+ async function commandCreateForm(args, env) {
1898
+ // ★ 2026-10-16 用户裁定:返回值里固定带上**本命令自身的耗时**(毫秒)。
1899
+ // 起点取在函数入口(含下面那次会话惰性初始化),终点取在结果产出之后 —— 即"从命令开始执行到
1900
+ // 执行完成"。取值不做开关、不进 skill 文档(用户明确要求"直接默认加上")。
1901
+ const startedAt = Date.now();
1902
+ // ★ 2026-10-21 用户裁定:耗时口径与 `createRequestDirect` **完全一致** —— 除总耗时外再给
1903
+ // 「每个接口多久」(`apiDurations`)与「登录取凭据多久」(`authMs`)。所以这里也把 host 包一层
1904
+ // 逐接口计时,整条命令(含智能填参那一趟)的请求都会被记上。
1905
+ const spans = [];
1906
+ const timedEnv = { ...env, host: withApiTiming(env.host, spans) };
1907
+ const { payload: fullPayload, fill, authMs } = await prepareCreateFormPayload(args, timedEnv);
1908
+ // 取数阶段已全部结束 ⇒ 这里拿到的是完整的一份;它既是判线要预留的键,也是最终返回的值。
1909
+ const apiDurations = summarizeApiSpans(spans);
1910
+ // 判线的位数预留:`durationMs` / `apiDurations` / `authMs` 一定会有;`fillDurationMs` **只有走通
1911
+ // chatScene 才有**,所以只在它会出现的这一轮才占位 —— 否则没走智能体时也白占十几个字符,边界上
1912
+ // 会让本可内联的表单提前落盘。`apiDurations` 此刻已经求出,直接按真值占(不必用哨兵估)。
1913
+ const reserve = {
1914
+ durationMs: DURATION_RESERVE_SENTINEL,
1915
+ apiDurations,
1916
+ authMs: DURATION_RESERVE_SENTINEL,
1699
1917
  };
1700
- const compacted = await compactFormOutput(fullPayload, env, 'last_create_form.json', [], {
1918
+ if (fill.durationMs !== undefined)
1919
+ reserve.fillDurationMs = DURATION_RESERVE_SENTINEL;
1920
+ const compacted = await compactFormOutput(fullPayload, timedEnv, 'last_create_form.json', [], {
1701
1921
  safeChars: CREATE_FORM_STDOUT_SAFE_CHARS,
1702
1922
  operation: 'workflow.createForm',
1703
- // 判线要量"最终打出去的那份文本",而 `durationMs` 的真值只能在压缩**之后**求(它统计的正是
1704
- // 整条命令),故此处按位数用哨兵值先把它算进判线 —— 高估只让边界上的表单提前落盘(安全方向)。
1705
- reserve: { durationMs: DURATION_RESERVE_SENTINEL },
1923
+ // 判线要量"最终打出去的那份文本",而 `durationMs` / `fillDurationMs` 的真值只能在压缩**之后**求
1924
+ // (它们统计的正是这段时间),故此处按位数用哨兵值先把它算进判线 —— 高估只让边界上的表单提前落盘
1925
+ // (安全方向)。
1926
+ reserve,
1706
1927
  });
1707
1928
  // 耗时并在 `data` 里(与 `outputChars` / `persistedResult` 这些运行期属性同处);**两条分支都带上**
1708
1929
  // —— 超线落盘那一档 `data` 只剩一句提示,并进去它才不会恰好在"大表单"时消失。
1709
- return workflowSuccess('workflow.createForm', { ...compacted, durationMs: Date.now() - startedAt });
1930
+ const result = {
1931
+ ...compacted,
1932
+ durationMs: Date.now() - startedAt,
1933
+ apiDurations,
1934
+ authMs,
1935
+ };
1936
+ // `fillDurationMs`(生成表单参数的耗时)**只在真走通 chatScene 时出现**:失败降级走的是老口径,
1937
+ // 返回面必须一个字都不多(对应需求「不符合预期则仍按原本逻辑执行和返回」)。
1938
+ if (fill.durationMs !== undefined)
1939
+ result.fillDurationMs = fill.durationMs;
1940
+ return workflowSuccess('workflow.createForm', result);
1710
1941
  }
1711
1942
  // ---------------------------------------------------------------------------
1712
1943
  // createForm:loadBase/loadFrame/loadMinor/formLayout + 字段输出 + fileUploadParams
@@ -1856,7 +2087,7 @@ async function buildCreateFormData(workflowId, env) {
1856
2087
  * 小e 环境下两处都不跑(`createRequest` 同档也跳过字段联动与显示属性联动,由浏览器页面自己算)。
1857
2088
  * 非小e 环境行为不变。
1858
2089
  */
1859
- if (!hasXiaoEEnv(env.ctx.env) && linkageActions.length) {
2090
+ if (!hasXiaoEEnv(env.ctx.env, env.ctx.getGlobalOptions?.()) && linkageActions.length) {
1860
2091
  const loadEntries = _entriesFromDefaults(defaults);
1861
2092
  try {
1862
2093
  onLoadLinkage = await runFieldLinkage(env, buildLinkageContext({ runtime, frame, layout: { form_id: frame.form_id }, linkageActions }), loadEntries, new Set(['onLoadForm']), defaults);
@@ -2372,6 +2603,23 @@ function splitValueAliases(raw) {
2372
2603
  }
2373
2604
  return out;
2374
2605
  }
2606
+ /**
2607
+ * 一个格子要送去查候选的「说法」清单——**按字段是单选还是多选分叉**:
2608
+ *
2609
+ * - **单选**:整串与各说法一并登记,由 `resolveBrowserAliases` 的「整串优先」先试整串
2610
+ * (名称本身自带逗号时不会被拆错);
2611
+ * - **多选**:逗号是**值分隔符**,只拆不拼——整串既不做「整串优先」,也**不进候选池**。
2612
+ * 否则「张三,李四」会被整串当成一个名称,恰好命中就静默吞成一个值(真机问题:
2613
+ * 多选浏览字段只写进去第一个对象),而且还会白花一次 complete / nameToId 往返。
2614
+ *
2615
+ * 多选里若单个对象名真的自带逗号,由 `名称(ID)` 显式指定(`resolveBrowserAliases` 认它)。
2616
+ */
2617
+ function lookupTermsOf(field, raw) {
2618
+ const aliases = splitValueAliases(raw);
2619
+ if (field && field.isMultiple)
2620
+ return aliases.length ? aliases : [raw];
2621
+ return aliases.length > 1 ? [raw, ...aliases] : [raw];
2622
+ }
2375
2623
  /** 浏览按钮字段解析不出来的统一出口。`candidates` **只含关键词真实命中**,绝不用首页枚举冒充。 */
2376
2624
  function browserAmbiguity(field, input, hits) {
2377
2625
  return new ValueAmbiguity({
@@ -2421,8 +2669,11 @@ function resolveBrowserAliases(field, raw, lookup) {
2421
2669
  return out;
2422
2670
  };
2423
2671
  const aliases = splitValueAliases(raw);
2424
- // 整串与各说法**并行**登记:整串命中(名称自带逗号)与切段命中进同一个池子判定
2425
- const terms = aliases.length > 1 ? [raw, ...aliases] : [raw];
2672
+ // 整串与各说法**并行**登记:整串命中(名称自带逗号)与切段命中进同一个池子判定。
2673
+ // 多选字段只登记切段结果(见 lookupTermsOf)。
2674
+ const terms = lookupTermsOf(field, raw);
2675
+ /** 「整串优先」只对**单选**成立:多选里逗号是值分隔符,整串本身不是候选名称。 */
2676
+ const wholeTermFirst = !(field && field.isMultiple) && aliases.length > 1;
2426
2677
  const pool = [];
2427
2678
  /** 每个词各自「字面同名」的候选(显式 ID 也算字面命中,它本来就是精确值)。 */
2428
2679
  const literalByTerm = new Map();
@@ -2435,8 +2686,8 @@ function resolveBrowserAliases(field, raw, lookup) {
2435
2686
  literalByTerm.set(term, candidates.filter((c) => String(c?.name ?? '') === term));
2436
2687
  }
2437
2688
  const poolHits = dedupe(pool);
2438
- // ① **整串优先**:原串本身就是一个名称 ⇒ 用它、不拆(名称自带逗号也不会被拆错);真重名则澄清
2439
- if (aliases.length > 1) {
2689
+ // ① **整串优先**(仅单选):原串本身就是一个名称 ⇒ 用它、不拆(名称自带逗号也不会被拆错);真重名则澄清
2690
+ if (wholeTermFirst) {
2440
2691
  const wholeHits = dedupe(literalByTerm.get(raw) || []);
2441
2692
  if (wholeHits.length === 1)
2442
2693
  return wholeHits[0];
@@ -2679,6 +2930,18 @@ function serializeValue(field, value, mappings, row, browserHints = {}) {
2679
2930
  const key = `${field.id}_${rowIndex}_${term}`;
2680
2931
  return { mapped: mappings[key] || [], completed: browserHints[key] || [] };
2681
2932
  };
2933
+ if (field.isMultiple) {
2934
+ // 多选:一个格子里的逗号是**值分隔符**,逐说法各解析出一条 `dataOptions`
2935
+ // (整串不当名称,见 lookupTermsOf)。某个说法有歧义时整条值都不落地,
2936
+ // 由该说法自己的候选澄清——已解析出的兄弟值**不丢**,因为澄清后会整条重发。
2937
+ for (const term of lookupTermsOf(field, raw)) {
2938
+ const pickedOne = resolveBrowserAliases(field, term, lookup);
2939
+ if (options.some((o) => o.optionId === pickedOne.id))
2940
+ continue;
2941
+ options.push({ optionId: pickedOne.id, content: pickedOne.name });
2942
+ }
2943
+ continue;
2944
+ }
2682
2945
  const picked = resolveBrowserAliases(field, raw, lookup);
2683
2946
  options.push({ optionId: picked.id, content: picked.name });
2684
2947
  }
@@ -2897,8 +3160,8 @@ async function resolveEntryValues(env, entries, formId) {
2897
3160
  continue;
2898
3161
  // 一个格子可写多个「说法」(逗号分隔):**整串与各说法一起登记**——这样"整串优先"要用的
2899
3162
  // 整串候选、以及各说法各自的候选,都在同一批里拿到(nameToId 一把批量发,未命中的再并行补)。
2900
- const aliases = splitValueAliases(raw);
2901
- const terms = aliases.length > 1 ? [raw, ...aliases] : [raw];
3163
+ // 多选字段只登记切段结果(逗号是值分隔符,见 lookupTermsOf)。
3164
+ const terms = lookupTermsOf(field, raw);
2902
3165
  // 候选来源二选一:控件自带 `browserModule`+`browserType` ⇒ 只走 complete(下一步补查),
2903
3166
  // **不进 nameToId 批次**;否则只走 nameToId。这样池子里永远只有一个来源,不会互相污染。
2904
3167
  const hasBrowserChannel = Boolean(field.browserModule && field.browserType);
@@ -3191,6 +3454,147 @@ function _detailValueIds(detail) {
3191
3454
  }
3192
3455
  return ids;
3193
3456
  }
3457
+ /**
3458
+ * 把一个值拆成「可比 token」集合:`id` / `optionId` / `name` / `content`,以及去掉尾部
3459
+ * `(id)` / `(id)` 后缀的纯文本。
3460
+ *
3461
+ * 为什么要拆:入参是**原始串**(`"银行(个人报销)(7)"`、`"20260999"`),基线是 **detail**
3462
+ * (`dataOptions:[{optionId:'7',content:'银行(个人报销)'}]`、`{content:'20260999'}`),
3463
+ * 直接比 `_entryValueIds` 与 `_detailValueIds` 会得到 `["银行(个人报销)(7)"]` vs
3464
+ * `["银行(个人报销)","7"]` 这种必不相等的形状。取交集判等才不会误判成"变了"。
3465
+ */
3466
+ function _pushComparableToken(tokens, raw) {
3467
+ const text = String(raw ?? '').trim();
3468
+ if (!text)
3469
+ return;
3470
+ tokens.add(text);
3471
+ const matched = text.match(/^(.*?)\s*[((]\s*([^()()]*?)\s*[))]$/);
3472
+ if (matched) {
3473
+ // 尾部括号里是 id 时(`银行(个人报销)(7)`、`泛微上海(100504500000311504)`)也要能对上
3474
+ if (matched[1].trim())
3475
+ tokens.add(matched[1].trim());
3476
+ if (matched[2].trim())
3477
+ tokens.add(matched[2].trim());
3478
+ }
3479
+ }
3480
+ function _entryComparableTokens(source) {
3481
+ const tokens = new Set();
3482
+ for (const item of listValues(source)) {
3483
+ if (item && typeof item === 'object') {
3484
+ for (const key of ['id', 'optionId', 'value', 'name', 'content']) {
3485
+ if (item[key] !== undefined && item[key] !== null)
3486
+ _pushComparableToken(tokens, item[key]);
3487
+ }
3488
+ }
3489
+ else {
3490
+ _pushComparableToken(tokens, item);
3491
+ }
3492
+ }
3493
+ return tokens;
3494
+ }
3495
+ /** 基线 detail 的可比 token(与 `_entryComparableTokens` 同一套口径)。 */
3496
+ function _baselineComparableTokens(detail) {
3497
+ const tokens = new Set();
3498
+ if (detail && typeof detail === 'object') {
3499
+ if (detail.content !== undefined && detail.content !== null)
3500
+ _pushComparableToken(tokens, detail.content);
3501
+ if (detail.dataText && typeof detail.dataText === 'object' && detail.dataText.content !== undefined) {
3502
+ _pushComparableToken(tokens, detail.dataText.content);
3503
+ }
3504
+ for (const option of detail.dataOptions || []) {
3505
+ if (!option || typeof option !== 'object')
3506
+ continue;
3507
+ if (option.optionId !== undefined && option.optionId !== null)
3508
+ _pushComparableToken(tokens, option.optionId);
3509
+ if (option.content !== undefined && option.content !== null)
3510
+ _pushComparableToken(tokens, option.content);
3511
+ if (option.name !== undefined && option.name !== null)
3512
+ _pushComparableToken(tokens, option.name);
3513
+ }
3514
+ }
3515
+ return tokens;
3516
+ }
3517
+ /**
3518
+ * 入参这条值是否**与基线相同**(即"用户没动过")。
3519
+ *
3520
+ * 判松不判严(任一 token 命中即算相同):漏判"变了"只会少触发一次联动(页面语义本来就是
3521
+ * 没变不触发);误判"没变"则会把用户真实修改的字段跳过,风险更大,所以这里宁可宽。
3522
+ * 基线里没有该字段 / 基线没值时一律视为"变了"(首次填写必须触发)。
3523
+ */
3524
+ function _sameAsBaseline(entry, detail) {
3525
+ const base = _baselineComparableTokens(detail);
3526
+ if (!base.size)
3527
+ return false;
3528
+ const incoming = _entryComparableTokens(entry.value);
3529
+ if (!incoming.size)
3530
+ return false;
3531
+ for (const token of incoming) {
3532
+ if (base.has(token))
3533
+ return true;
3534
+ }
3535
+ return false;
3536
+ }
3537
+ /**
3538
+ * 「值真的变了的字段 id」——`changeField` 类联动的触发依据(2026-10-20 用户裁定)。
3539
+ *
3540
+ * 旧口径把**入参里出现的字段**一律当成变更字段(模拟页面 onchange),于是"把表单默认值原样
3541
+ * 回传"会伪造出一次"用户修改":真机坑是 `合同号=20260999`(= 该字段 `initialValue`)被
3542
+ * `createForm.data` 带出来、又被整份回传给 `createRequest`,触发合同号的 changeField 联动,
3543
+ * 服务端按合同重算 `相关客户` 等 7 个字段把 `相关客户` 置空 ⇒ 必填缺失。
3544
+ *
3545
+ * 现在的口径:**只有值与基线不同才算变更**。
3546
+ * - 发起流程:基线 = 表单默认值(`context.defaults`)
3547
+ * - 编辑流程:基线 = 编辑前的字段值(`context.currentDetails`,来自 edit-load 的 `dataDetails`)
3548
+ *
3549
+ * 明细行按 `(subFormId, dataIndex)` 定位基线;明细默认值常常没有行号,此时回落到"同字段任一基线"
3550
+ * (见 `byField`)——宁可判"没变",也不要让无行号的默认值把每一行都判成变更。
3551
+ */
3552
+ function _changedFieldIds(entries, baseline) {
3553
+ const byRow = new Map();
3554
+ const byField = new Map();
3555
+ const pushTo = (map, key, item) => {
3556
+ const bucket = map.get(key);
3557
+ if (bucket)
3558
+ bucket.push(item);
3559
+ else
3560
+ map.set(key, [item]);
3561
+ };
3562
+ for (const detail of baseline || []) {
3563
+ if (!detail || typeof detail !== 'object')
3564
+ continue;
3565
+ const fieldId = String((detail.formField && detail.formField.id) ?? '').trim();
3566
+ if (!fieldId)
3567
+ continue;
3568
+ const subFormId = String((detail.subForm && detail.subForm.id) ?? '').trim();
3569
+ const hasRow = detail.dataIndex !== undefined && detail.dataIndex !== null;
3570
+ const dataIndex = hasRow ? String(detail.dataIndex) : '';
3571
+ pushTo(byField, fieldId, detail);
3572
+ if (hasRow)
3573
+ pushTo(byRow, `${subFormId}\u0000${fieldId}\u0000${dataIndex}`, detail);
3574
+ }
3575
+ const changed = [];
3576
+ const seen = new Set();
3577
+ for (const entry of entries) {
3578
+ if (!entry.field || typeof entry.field !== 'object')
3579
+ continue;
3580
+ const fieldId = String(entry.field.id ?? '').trim();
3581
+ if (!fieldId || seen.has(fieldId))
3582
+ continue;
3583
+ const row = (entry.row && typeof entry.row === 'object') ? entry.row : null;
3584
+ const subFormId = row ? String((row.subForm && row.subForm.id) ?? '').trim() : String(entry.field.subFormId ?? '').trim();
3585
+ const hasRow = Boolean(row) && row.dataIndex !== undefined && row.dataIndex !== null;
3586
+ const candidates = hasRow
3587
+ ? (byRow.get(`${subFormId}\u0000${fieldId}\u0000${String(row.dataIndex)}`) || [])
3588
+ : (byField.get(fieldId) || []);
3589
+ const fallback = hasRow ? (byField.get(fieldId) || []) : [];
3590
+ const unchanged = [...candidates, ...fallback].some((detail) => _sameAsBaseline(entry, detail));
3591
+ if (unchanged)
3592
+ continue;
3593
+ seen.add(fieldId);
3594
+ changed.push(fieldId);
3595
+ }
3596
+ return changed;
3597
+ }
3194
3598
  function _linkageOptions(sourceContent) {
3195
3599
  if (!sourceContent || typeof sourceContent !== 'string' || !sourceContent.trim())
3196
3600
  return [];
@@ -3315,9 +3719,10 @@ function _mainValueMap(details) {
3315
3719
  }
3316
3720
  return result;
3317
3721
  }
3318
- async function runFieldLinkage(env, ctx, entries, actionTypes, allDetails, isEdit = false) {
3319
- const changedIds = entries.filter((e) => e.field && typeof e.field === 'object' && String(e.field.id ?? '').trim())
3320
- .map((e) => String(e.field.id));
3722
+ async function runFieldLinkage(env, ctx, entries, actionTypes, allDetails, isEdit = false, baseline) {
3723
+ // 只有「值与基线(发起=表单默认值 / 编辑=编辑前值)不同」的字段才算变更字段,
3724
+ // 见 `_changedFieldIds` 的注释(2026-10-20 用户裁定:值没变就不该触发 changeField 联动)。
3725
+ const changedIds = _changedFieldIds(entries, baseline);
3321
3726
  const runDataList = _buildLinkageRunData(ctx.linkageActions, changedIds, actionTypes);
3322
3727
  const neededIds = _neededLinkageFieldIds(ctx.linkageActions, runDataList);
3323
3728
  const dataList = [];
@@ -4018,7 +4423,8 @@ function mainFilledRows(fields, values, hiddenIds, disabledIds, compsFieldIds) {
4018
4423
  * 表格口径**,只换结论句与末段(那边要说清"还没发起 + 还差哪几项");`NEEDS_CONFIRMATION` 不给
4019
4424
  * 文本(要先解决 `ambiguities` 再重发)。
4020
4425
  *
4021
- * 内容分四段:结论(`message`,命令已写好的中文)→ 编号/链接 → 主表「已填内容」两列表
4426
+ * 内容分四段:结论(`message`,命令已写好的中文;**只用来渲染,渲染后这个键不再返回**)→ 编号/链接
4427
+ * → 主表「已填内容」两列表
4022
4428
  * (系统字段如流程标题在前,业务字段按表单布局顺序随后;行由 `mainFilledRows` + `systemFilledRows`
4023
4429
  * 算好传进来,命令侧只有这一处口径)→ 各明细表(Excel 列形态)→ 提交失败原因 / 草稿缺项提醒(有才出现)。
4024
4430
  */
@@ -4262,6 +4668,47 @@ function xiaoEUrlParamValue(value) {
4262
4668
  * 明细字段(`preview` 里带 `dataIndex`)按**行号升序**取值、用 `;` 连接;主表字段就是它自己的值。
4263
4669
  * 值为空(没解析出内容)的字段整个省略,不生成空参数。
4264
4670
  */
4671
+ /**
4672
+ * 小e 档交给浏览器页面的字段值:只留**可见且可编辑**的那些(2026-10-21 用户裁定)。
4673
+ *
4674
+ * 依据是 `computeLinkageVisibility` 用句柄里本地 `viewLinkages` 求出来的隐藏 / 禁用,再加上字段
4675
+ * 自身的只读、禁填、初始隐藏(`display === false`)与布局隐藏(`hiddenCellFieldIds`)。
4676
+ *
4677
+ * 过滤的**只有值**,不改页面结构:这些字段在表单页里本来就填不进去(页面自己会拦),把值塞过去
4678
+ * 只会让页面报错或让用户看到"填了但没生效"。
4679
+ */
4680
+ function editablePreviewForXiaoE(preview, fields, visibility) {
4681
+ const fieldById = new Map();
4682
+ for (const field of toArray(fields)) {
4683
+ const id = String(field?.id ?? '').trim();
4684
+ if (id)
4685
+ fieldById.set(id, field);
4686
+ }
4687
+ const kept = [];
4688
+ for (const item of toArray(preview)) {
4689
+ const fieldId = String(item?.fieldId ?? '').trim();
4690
+ if (!fieldId)
4691
+ continue;
4692
+ const field = fieldById.get(fieldId) || {};
4693
+ if (field.readOnly || field.disableEdit || field.display === false)
4694
+ continue;
4695
+ if (visibility.hiddenFieldIds.has(fieldId))
4696
+ continue;
4697
+ const subFormId = String(field.subFormId ?? '').trim();
4698
+ if (subFormId && visibility.hiddenSubforms.has(subFormId))
4699
+ continue;
4700
+ // 行键口径与 `computeLinkageVisibility` 完全一致('main' 或 `<subFormId>:<dataIndex>`,
4701
+ // 明细行没给行号时按第 1 行算)。
4702
+ const hasRow = item?.dataIndex !== undefined && item?.dataIndex !== null;
4703
+ const rowKey = rowKeyFor(subFormId, hasRow ? item.dataIndex : undefined);
4704
+ if (visibility.rowHidden[rowKey]?.has(fieldId))
4705
+ continue;
4706
+ if (visibility.rowDisabled[rowKey]?.has(fieldId))
4707
+ continue;
4708
+ kept.push(item);
4709
+ }
4710
+ return kept;
4711
+ }
4265
4712
  function xiaoECustomParams(preview) {
4266
4713
  const ordered = [];
4267
4714
  const mainValues = new Map();
@@ -4336,9 +4783,50 @@ function renderXiaoEFillFormMarkdown(params) {
4336
4783
  };
4337
4784
  return `<browsertool>\n${JSON.stringify(payload, null, 2)}\n</browsertool>`;
4338
4785
  }
4339
- // ---------------------------------------------------------------------------
4340
- // createRequest(合并原 createPrepare + createApply)
4341
- // ---------------------------------------------------------------------------
4786
+ /**
4787
+ * 把命令耗时并回 `data` 顶层 —— `createRequest` 与 `createRequestDirect` **共用这一份口径**
4788
+ * (2026-10-21 用户裁定:`createRequest` 也要给耗时,且与 `createRequestDirect` 一致):
4789
+ * - `durationMs` = 命令入口 → 结果产出;
4790
+ * - `apiDurations` = 每个接口(并行批次合成一个 key,取墙钟、不叠加);
4791
+ * - `authMs` = 登录取凭据(首次会话解析)的墙钟 —— 这一段走 auth 层自己的 fetch、不经
4792
+ * `host.workflowRequest`,`apiDurations` 统计不到,单独给一个数才能对上账。
4793
+ *
4794
+ * `data` 不是普通对象时原样返回(被替换成字符串 / 数组的返回面一个字都不动)。
4795
+ */
4796
+ function stampCommandTimings(envelope, timing) {
4797
+ const data = envelope?.data;
4798
+ if (!data || typeof data !== 'object' || Array.isArray(data))
4799
+ return envelope;
4800
+ const mutable = data;
4801
+ mutable.durationMs = Date.now() - timing.startedAt;
4802
+ mutable.apiDurations = summarizeApiSpans(timing.spans);
4803
+ mutable.authMs = timing.authMs;
4804
+ return envelope;
4805
+ }
4806
+ /**
4807
+ * `createRequest` 的耗时外壳:建计时通道 + 量登录耗时 + 把三个耗时键并回结果。
4808
+ *
4809
+ * 参数明显不合法(`action` 不对 / 没 `context`)时**不先登录** —— 那两种情况内层会直接抛
4810
+ * 参数错,为一个注定失败的调用白走一趟统一登录不值当;真正的报错措辞仍由内层给出。
4811
+ */
4812
+ async function commandCreateRequest(args, env, options = {}) {
4813
+ const startedAt = Date.now();
4814
+ const spans = options.apiSpans ?? [];
4815
+ // `createRequestDirect` 已经把 host 包过一层(它的片段数组覆盖到表单载入那一段)⇒ 这里不能再包,
4816
+ // 否则每个请求会被记两遍(key 里会出现重名)。
4817
+ const timedEnv = options.apiSpans ? env : { ...env, host: withApiTiming(env.host, spans) };
4818
+ const action = String(args.action ?? '').trim();
4819
+ let authMs = 0;
4820
+ if ((action === 'draft' || action === 'submit') && String(args.context ?? '')) {
4821
+ // 会话是惰性的:内层的 `verifyContextLease` 本来也会取一次,这里只是把它**提前并单独计时**,
4822
+ // 拿到的仍是同一份缓存,不额外多发请求。
4823
+ const authStartedAt = Date.now();
4824
+ await timedEnv.host.getSession();
4825
+ authMs = Date.now() - authStartedAt;
4826
+ }
4827
+ const envelope = await runCreateRequest(args, timedEnv, options);
4828
+ return stampCommandTimings(envelope, { startedAt, spans, authMs });
4829
+ }
4342
4830
  /**
4343
4831
  * `workflow.createRequest` —— **一条命令**完成「字段解析 → 联动 → 必填校验 → 保存 → 发起」。
4344
4832
  *
@@ -4350,7 +4838,7 @@ function renderXiaoEFillFormMarkdown(params) {
4350
4838
  * 返回**不消费**句柄,于是模型补齐字段后可以带**同一个 context** 重发,不必回头重跑 createForm;
4351
4839
  * 一旦消费,就只能按句柄状态分流(`ALREADY_APPLIED` / 结果不确定 / 并发占用)。
4352
4840
  */
4353
- async function commandCreateRequest(args, env) {
4841
+ async function runCreateRequest(args, env, options = {}) {
4354
4842
  const action = String(args.action ?? '').trim();
4355
4843
  if (action !== 'draft' && action !== 'submit') {
4356
4844
  throw workflowValidationError('action_required', 'action 必填,且只能是 draft(仅存草稿)或 submit(提交审批)');
@@ -4395,17 +4883,22 @@ async function commandCreateRequest(args, env) {
4395
4883
  * (`buildCreateFormData`,那边的联动结果要用来算字段的必填 / 可编辑清单);这里两者都不跑 ——
4396
4884
  * 本档既不做必填判定、也不发写请求。非小e 环境行为不变。
4397
4885
  */
4398
- const isXiaoE = hasXiaoEEnv(env.ctx.env);
4886
+ const isXiaoE = hasXiaoEEnv(env.ctx.env, env.ctx.getGlobalOptions?.());
4399
4887
  const linkageDetails = isXiaoE
4400
4888
  ? []
4401
- : await runFieldLinkage(env, linkageContextFromPayload(context), entries, null, merged, false);
4889
+ : await runFieldLinkage(env, linkageContextFromPayload(context), entries, null, merged, false, context.defaults || []);
4402
4890
  // 明细默认值无行号,而判缺失/写载荷都按行号索引 ⇒ 按用户已建行补齐缺口(V12.1.35,
4403
4891
  // 详见 fillDetailDefaultsByRow 注释)。放在联动之后合并,联动带出的值仍然优先。
4404
4892
  const merged2 = fillDetailDefaultsByRow(mergeLinkageDetails(merged, linkageDetails), context.defaults || [], userDetailRows(entries));
4405
4893
  const attachmentFolderIds = _attachmentFolderIds(context.rawUploadFields || {}, context.attachConfigs || {}, fields, _mainValueMap(merged2));
4406
4894
  const folderPayload = Object.keys(attachmentFolderIds).length ? { attachmentFolderIds } : {};
4407
4895
  const [hiddenSubforms, rowHidden, rowExtraRequired, rowDisabled] = isXiaoE
4408
- ? emptyLinkageVisibility()
4896
+ ? (options.xiaoEViewLinkageFilter
4897
+ // 2026-10-21 用户裁定(`createRequestDirect` 这一档):小e 档**照跑显示属性联动**,只是不跑字段联动
4898
+ // —— 它要据此把页面本来就填不进去的字段(隐藏 / 禁用)从要填的值里剔掉。
4899
+ // 第三参传 `[]`(本次没有联动带出的值)时,求值只用表单默认值 + 本次入参 + 系统字段值。
4900
+ ? computeLinkageVisibility(context.viewLinkages, entries, context.defaults || [], [], context.systemFieldValues, fields)
4901
+ : emptyLinkageVisibility())
4409
4902
  : computeLinkageVisibility(context.viewLinkages, entries, context.defaults || [], linkageDetails, context.systemFieldValues, fields);
4410
4903
  const initialHidden = new Set(fields.filter((f) => f.display === false).map((f) => String(f.id ?? '')));
4411
4904
  if (initialHidden.size) {
@@ -4518,17 +5011,16 @@ async function commandCreateRequest(args, env) {
4518
5011
  * 渲染档(2026-10-15 用户裁定):`finalMarkdown` **整体就是一段 `<browsertool>` 标签块**;本轮回复正文
4519
5012
  * = 这段内容本身(从 `<browsertool>` 到 `</browsertool>`),原样输出、**不要包在代码块里**。
4520
5013
  *
4521
- * **stdout 形态**(同日第二次裁定「xiaoe 下应该直接输出 `<browsertool>` 标签」):这一档的 stdout
4522
- * **就是那段标签块本身,不套 JSON 信封** —— 判定与落点都在 `shortcuts/workflow/index.ts` 的
4523
- * `rawStdoutOf()` / `executeAndPrint()`(放在命令层是因为"打到 stdout 的形态"属命令层职责)。
4524
- * 所以下面的 `message` / `workflow` 等键**不会出现在 stdout 上**,它们只对 `--input-ref` 这类
4525
- * 程序化消费者有意义;模型侧看到的就是标签块,读法见 `workflow-create.md` 第 3 步与处置表。
5014
+ * **stdout 形态**(2026-10-21 用户裁定,**改口径**):这一档同样**走 JSON 信封**,标签块放在
5015
+ * `data.finalMarkdown` 里 —— 与 `createRequestDirect` 统一,模型侧只有一条读法;`durationMs` /
5016
+ * `apiDurations` / `authMs` 三个耗时键也因此在这一档可见。原来的「stdout 直接就是标签块、不套
5017
+ * 信封」已作废(`shortcuts/workflow/index.ts` 的直出通道随之删除)。
5018
+ * 所以下面的 `message` 要明确告诉模型:从 `data.finalMarkdown` 取内容、原样作为本轮回复正文。
4526
5019
  *
4527
- * 判据:`auth/xiaoe.ts` 的 `hasXiaoEEnv(env.ctx.env)`(2026-10-16 用户裁定,统一走公共判据)。
4528
- * 它已覆盖宿主的两种形态 —— 网页端(`XIAOE_USER_TOKEN` + `XIAOE_DOMAIN` 平台凭证)与桌面客户端
4529
- * (`XIAOE_DATA_ROOT` / `XIAOE_ROBOT_DATA_ROOT` / `XIAOE_USER_SETTINGS_PATH` / `XIAOE_ROBOT_ROOT`
4530
- * 四个运行期路径变量),同时仍对 `WORKBUDDY_*` / `CODEX_*` 做排他短路。本处复用上面算好的
4531
- * `isXiaoE`(同一判据,只求值一次)。
5020
+ * 判据:桌面客户端可由 Agent 根据宿主明确上下文传入全局参数 `--runtime-agent xiaoe`;
5021
+ * 传入环境中存在任意 `XIAOE_*` 变量时也直接判定为小e环境。
5022
+ * 历史会话和本地文件本身不参与宿主身份判断。
5023
+ * 本处复用上面算好的 `isXiaoE`(同一判据,只求值一次)。
4532
5024
  *
4533
5025
  * 本档**不跑字段联动、也不跑显示属性联动**(2026-10-16 用户裁定,见上方 `isXiaoE` 的注释):
4534
5026
  * 所以这里既没有「字段联动带出」的提示,`hiddenSubforms` / `rowExtraRequired` / `rowDisabled`
@@ -4538,16 +5030,22 @@ async function commandCreateRequest(args, env) {
4538
5030
  // `src` 要的是绝对地址:句柄里带走的那份 baseUrl 为准(与句柄同源,避免"换了域名后旧句柄拼出别的站"),
4539
5031
  // 旧版本句柄可能没带 ⇒ 回落到当前会话的 baseUrl。
4540
5032
  const xiaoEBaseUrl = String(context.baseUrl ?? '') || (await getSessionContext(env)).baseUrl;
5033
+ // 交给页面的值:`createRequestDirect` 这一档先按显示属性联动过滤掉不可编辑字段(见上方 options 注释),
5034
+ // 其余入口(`createRequest`)原样传 `preview`。
5035
+ const fillPreview = options.xiaoEViewLinkageFilter
5036
+ ? editablePreviewForXiaoE(preview, fields, { hiddenSubforms, rowHidden, rowDisabled, hiddenFieldIds })
5037
+ : preview;
4541
5038
  return workflowSuccess('workflow.createRequest', {
4542
5039
  status: 'BROWSER_FILL',
4543
5040
  contextId: token,
4544
- message: '当前是小e环境:本次不向服务端写入,改由浏览器页面完成填单。命令已把 <browsertool> 标签块直接输出到 stdout(本档不打 JSON 信封),把那段输出原样作为本轮回复正文即可(不要包在代码块里、不要加任何前后说明)。',
5041
+ // 本档只有一种输出形态(JSON 信封),指引也就只有一条:从 `data.finalMarkdown` 取内容。
5042
+ message: '当前是小e环境:本次不向服务端写入,改由浏览器页面完成填单。本档输出的是 JSON 信封(额外带 durationMs / apiDurations / authMs 耗时),`data.finalMarkdown` 就是那段 <browsertool> 标签块(其中的换行在 JSON 里以 \\n 转义)。把 `data.finalMarkdown` 的内容原样作为本轮回复正文:从 <browsertool> 到 </browsertool>、换行还原成真实换行,不要包在代码块里、不要加任何前后说明;JSON 信封本身不要回给用户。',
4545
5043
  workflow: context.workflow,
4546
5044
  finalMarkdown: renderXiaoEFillFormMarkdown({
4547
5045
  baseUrl: xiaoEBaseUrl,
4548
5046
  title: String(context.workflow?.name ?? ''),
4549
5047
  workflowId: String(context.workflow?.id ?? context.runtime?.workflow_id ?? ''),
4550
- customParams: xiaoECustomParams(preview),
5048
+ customParams: xiaoECustomParams(fillPreview),
4551
5049
  }),
4552
5050
  });
4553
5051
  }
@@ -4558,10 +5056,13 @@ async function commandCreateRequest(args, env) {
4558
5056
  // 表单默认值,2026-10-14 起)+ 明细 Excel 列 + 待补必填项清单。模型不必自己拼这些表格,也不必再
4559
5057
  // 遍历 `resolvedValues` 拼待补清单;因此这里**不再返回** `resolvedValues`(渲染素材只在命令内部用)。
4560
5058
  const pendingRequestName = String(args.requestName ?? sysDefaults.requestName ?? context.runtime.default_request_name ?? '').trim();
5059
+ // ★ 2026-10-20 用户裁定:**有 `finalMarkdown` 的状态不再返回 `message`**。
5060
+ // 这条路一定有渲染档(`renderCreatePendingMarkdown` 至少输出结论句 + 待补清单),
5061
+ // 而 `message` 说的是同一件事、口气还是"对模型下指令"("带同一 context 重发 action=submit"),
5062
+ // 模型看到就会在 `finalMarkdown` 之外多输出一段、顺带把内部机制漏给用户。
4561
5063
  return workflowSuccess('workflow.createRequest', {
4562
5064
  status: 'NEEDS_INPUT',
4563
5065
  contextId: token,
4564
- message: '缺少必填字段,本次未发起流程。请把下面这些字段一次问齐后,带同一 context 重发 action=submit。',
4565
5066
  missingFields: missingAll,
4566
5067
  finalMarkdown: renderCreatePendingMarkdown({ missingFields: missingAll, linkageResult },
4567
5068
  // 标题行按"当前还没填到标题"的口径取(可能为空 ⇒ 不出现该行),与落地档的 `requestName` 兜底分开。
@@ -4779,9 +5280,201 @@ async function commandCreateRequest(args, env) {
4779
5280
  // 标题行用**本次真正落库的** `requestName`(与 `prepared.requestName` 同源),不再是"可能为空"的口径。
4780
5281
  if (['CREATED', 'CREATED_SUBMITTED', 'CREATED_SUBMIT_FAILED'].includes(String(result.status))) {
4781
5282
  result.finalMarkdown = renderCreateResultMarkdown(result, [...filledTitleRows(requestName), ...filledFieldRows]);
5283
+ // ★ 渲染档的**结论句就是 `message`**(`renderCreateResultMarkdown` 第一行取的它),所以只能在
5284
+ // 渲染之后再摘键——不是不用它,是**不单独返回它**:返回面里 `message` 与 `finalMarkdown` 说的
5285
+ // 是同一句话,模型只会挑一个输出,留着就是给自己制造"两个声音"(2026-10-20 用户裁定)。
5286
+ delete result.message;
4782
5287
  }
4783
5288
  return workflowSuccess('workflow.createRequest', result);
4784
5289
  }
5290
+ // ---------------------------------------------------------------------------
5291
+ // 接口耗时(`workflow.createRequestDirect`)
5292
+ // ---------------------------------------------------------------------------
5293
+ /**
5294
+ * 接口**简称**:`path` → 模型看得懂的短名。
5295
+ *
5296
+ * 只登记这条命令真会打到的接口(解析工作流 / 载入表单 / 联动 / 写库 / 校验 / 浏览按钮);
5297
+ * 没登记的按路径最后一段兜底,不会因为漏登记就把计时丢掉。
5298
+ */
5299
+ const API_SHORT_NAMES = {
5300
+ [FORM_ENDPOINTS.workflowCreateList]: 'newFlowList',
5301
+ [FORM_ENDPOINTS.loadBase]: 'loadBase',
5302
+ [FORM_ENDPOINTS.loadFrame]: 'loadFrame',
5303
+ [FORM_ENDPOINTS.loadMinor]: 'loadMinor',
5304
+ [FORM_ENDPOINTS.formLayout]: 'formLayout',
5305
+ [FORM_ENDPOINTS.tenantSetting]: 'tenantSetting',
5306
+ [FORM_ENDPOINTS.fdLinkage]: 'fdLinkage',
5307
+ [FORM_ENDPOINTS.save]: 'saveFormData',
5308
+ [FORM_ENDPOINTS.create]: 'flowCreate',
5309
+ [FORM_ENDPOINTS.flowSubmit]: 'flowSubmit',
5310
+ [FORM_ENDPOINTS.signInputInfo]: 'signInputInfo',
5311
+ [FORM_ENDPOINTS.submit]: 'submitRequest',
5312
+ [FORM_ENDPOINTS.nameToId]: 'relateName2Id',
5313
+ [FORM_ENDPOINTS.verifyNodeFieldByList]: 'verifyNodeField',
5314
+ [FORM_ENDPOINTS.conformanceSet]: 'conformanceSet',
5315
+ [FORM_ENDPOINTS.verificationRequestData]: 'verificationRequestData',
5316
+ [FORM_ENDPOINTS.verificationConditionRule]: 'verificationConditionRule',
5317
+ [WORKFLOW_ENDPOINTS.MINE_TREE]: 'mineTree',
5318
+ [WORKFLOW_ENDPOINTS.MEMORY_PROMPT]: 'memoryPrompt',
5319
+ [WORKFLOW_ENDPOINTS.WORKFLOW_PATH_BROWSER]: 'workflowPathBrowser',
5320
+ [WORKFLOW_ENDPOINTS.RESOURCE_BROWSER]: 'resourceBrowser',
5321
+ [WORKFLOW_ENDPOINTS.HRM_RESOURCE_BROWSER]: 'hrmResourceBrowser',
5322
+ [WORKFLOW_ENDPOINTS.DEPT_BROWSER]: 'deptBrowser',
5323
+ [WORKFLOW_ENDPOINTS.SUBCOMPANY_BROWSER]: 'subcompanyBrowser',
5324
+ [WORKFLOW_ENDPOINTS.GROUP_BROWSER]: 'groupBrowser',
5325
+ [WORKFLOW_ENDPOINTS.ROLE_BROWSER]: 'roleBrowser',
5326
+ [WORKFLOW_ENDPOINTS.POSITION_BROWSER]: 'positionBrowser',
5327
+ [WORKFLOW_ENDPOINTS.NODE_BROWSER]: 'nodeBrowser',
5328
+ [WORKFLOW_ENDPOINTS.FIELD_OPTION_BROWSER]: 'fieldOptionBrowser',
5329
+ [WORKFLOW_ENDPOINTS.REQUEST_DETAIL]: 'getWorkflowRequest',
5330
+ // 智能填参(非 workflow 前缀,靠 `aiappuniquekey` 头定位智能体应用,见 chatscene.ts)
5331
+ '/api/ai/application/chat/chatScene': 'chatScene',
5332
+ };
5333
+ function apiShortName(path) {
5334
+ const text = String(path ?? '');
5335
+ const known = API_SHORT_NAMES[text];
5336
+ if (known)
5337
+ return known;
5338
+ const segments = text.split('/').filter(Boolean);
5339
+ return segments.length ? segments[segments.length - 1] : text || 'unknown';
5340
+ }
5341
+ /**
5342
+ * 给 host 套一层**逐接口计时**(只包请求,不改返回、不吞异常)。
5343
+ *
5344
+ * `finally` 里落片段:请求失败(超时 / 业务错误 / 抛错)同样要记 —— 恰恰是这类请求最需要看到耗时。
5345
+ */
5346
+ function withApiTiming(host, spans) {
5347
+ const record = async (path, run) => {
5348
+ const start = Date.now();
5349
+ try {
5350
+ return await run();
5351
+ }
5352
+ finally {
5353
+ spans.push({ name: apiShortName(path), start, end: Date.now() });
5354
+ }
5355
+ };
5356
+ return {
5357
+ ...host,
5358
+ workflowRequest: (request) => record(request?.path, () => host.workflowRequest(request)),
5359
+ workflowBinaryRequest: (request) => record(request?.path, () => host.workflowBinaryRequest(request)),
5360
+ };
5361
+ }
5362
+ /**
5363
+ * 把逐次请求的片段收成「一条命令里每个接口花了多久」。
5364
+ *
5365
+ * 口径(2026-10-21 用户裁定):
5366
+ * - **并行**(时间区间重叠)的接口合成**一个 key**:各简称用 `_` 拼起来(多个时按字母序,保证同一批
5367
+ * 接口每次输出同一个 key),**值取这一批的墙钟耗时**(最早开始 → 最晚结束),**不是**把批内每个
5368
+ * 接口的耗时相加;
5369
+ * - 串行接口各自一个 key,值就是它自己的耗时;
5370
+ * - 同一个 key 出现多次(同一接口被调了两轮)按累加计 —— 那确实是两笔独立开销。
5371
+ *
5372
+ * 重叠判定用「区间扫描」而不是推断代码里的并发结构:只要两个请求在时间上真重叠就算一批,
5373
+ * 批量接口内部再起并发也照样能反映出来。
5374
+ */
5375
+ function summarizeApiSpans(spans) {
5376
+ const sorted = [...spans].sort((a, b) => (a.start - b.start) || (a.end - b.end));
5377
+ const groups = [];
5378
+ let current = [];
5379
+ let reach = Number.NEGATIVE_INFINITY;
5380
+ for (const span of sorted) {
5381
+ // 端点相接(`start === reach`)按串行算:前一个已经返回,后一个才开始。
5382
+ if (current.length && span.start < reach) {
5383
+ current.push(span);
5384
+ reach = Math.max(reach, span.end);
5385
+ }
5386
+ else {
5387
+ if (current.length)
5388
+ groups.push(current);
5389
+ current = [span];
5390
+ reach = span.end;
5391
+ }
5392
+ }
5393
+ if (current.length)
5394
+ groups.push(current);
5395
+ const out = {};
5396
+ for (const group of groups) {
5397
+ const names = group.map((item) => item.name);
5398
+ const key = names.length === 1 ? names[0] : [...names].sort().join('_');
5399
+ const cost = Math.max(0, Math.round(Math.max(...group.map((item) => item.end)) - Math.min(...group.map((item) => item.start))));
5400
+ out[key] = (out[key] ?? 0) + cost;
5401
+ }
5402
+ return out;
5403
+ }
5404
+ /**
5405
+ * `workflow.createRequestDirect` —— **临时命令**:把「载入表单 + 智能填参」和「发起」串成一步。
5406
+ *
5407
+ * 等价于「先跑 `createForm`(带 `userQuestion`),再把它的 `contextId` 与 `data` 原样交给
5408
+ * `createRequest`」,区别只在一次进程内完成、不给模型中间态。
5409
+ *
5410
+ * ## 四条口径
5411
+ * 1. **取数阶段复用** `prepareCreateFormPayload`(`createForm` 的同一段代码),所以澄清 / 表单不可发起
5412
+ * 这类错误**原样上抛**,新命令一条都不会吞,也不会在澄清时硬着头皮发写请求。
5413
+ * 2. 智能填参走通 ⇒ 用它算出的 `data`;**没走通(超时 / 返回体不合法 / 未传 userQuestion)⇒ 按
5414
+ * `data` 为空继续** —— 交给 `createRequest` 自己的必填校验去判定(`submit` 缺必填不写库、
5415
+ * `draft` 直接建草稿),与"模型没填任何字段就调 createRequest"完全等价。
5416
+ * 3. **小e 档**:不跑字段联动(页面自己会重算),但**跑显示属性联动并把不可编辑字段过滤掉**
5417
+ * (`xiaoEViewLinkageFilter`),交出去的值只含页面真能填的字段。
5418
+ * 4. 最终返回 **`createRequest` 的结果**(`data` 与各状态语义完全一致,`action` 仍是
5419
+ * `workflow.createRequest`),只额外挂三个耗时键(见下)。JSON 信封的 `operation` 是
5420
+ * `workflow.createRequestDirect`(由命令层盖,见 `successEnvelope`)。
5421
+ *
5422
+ * ## 小e 档也走 JSON(2026-10-21 用户裁定,已推广到原命令)
5423
+ * 原命令在小e 下曾直出 `<browsertool>`,那条路会把耗时藏掉。现在**两条命令统一走 JSON 信封**:
5424
+ * `data.finalMarkdown` 仍是同一段标签块,`data.durationMs` / `data.apiDurations` / `data.authMs`
5425
+ * 一并可见;`message` 告知模型从 `finalMarkdown` 取内容原样回复。
5426
+ *
5427
+ * ## 耗时
5428
+ * - `data.durationMs` = 命令总耗时;
5429
+ * - `data.apiDurations` = 每个接口(并行批次合并)的耗时;
5430
+ * - `data.authMs` = 登录取凭据(首次会话解析)的耗时 —— 这段走 auth 层自己的 fetch,不经
5431
+ * `host.workflowRequest`,`apiDurations` 统计不到,单独给一个数才能对上账
5432
+ * (`durationMs` ≈ `apiDurations` 合计 + `authMs` + 本地解析/渲染的零头)。
5433
+ * 三者都只在 `data` 是普通对象时挂;本命令与 `createRequest` 用的是**同一个** `stampCommandTimings`,
5434
+ * 差别只在时间区间(这里从命令入口起算、覆盖表单载入那一段)。
5435
+ */
5436
+ async function commandCreateRequestDirect(args, env) {
5437
+ const startedAt = Date.now();
5438
+ const action = String(args.action ?? '').trim();
5439
+ // 先校验 action 再取数:参数错就没必要白跑一趟表单载入(那是最贵的一段)。
5440
+ if (action !== 'draft' && action !== 'submit') {
5441
+ throw workflowValidationError('action_required', 'action 必填,且只能是 draft(仅存草稿)或 submit(提交审批)');
5442
+ }
5443
+ // 计时从**命令入口**开始算(含会话解析、工作流消歧、表单载入、智能填参、写库、校验)。
5444
+ const spans = [];
5445
+ const timedEnv = { ...env, host: withApiTiming(env.host, spans) };
5446
+ const { payload, fill, authMs } = await prepareCreateFormPayload(args, timedEnv);
5447
+ const contextId = String(payload.contextId ?? '');
5448
+ if (!contextId) {
5449
+ // `prepareCreateFormPayload` 正常返回时 contextId 必然存在;这里只是不带着空句柄去调写接口。
5450
+ throw workflowValidationError('context_missing', '表单载入未产出可用句柄,本次未发起任何写入,请重试');
5451
+ }
5452
+ // ★ 智能体没给出可用 data ⇒ `{}`(不是老口径的 historyRequestData:本命令不做二次推断,
5453
+ // 有值就用、没有就交给必填校验)。
5454
+ const data = (fill.applied && fill.data && typeof fill.data === 'object')
5455
+ ? fill.data
5456
+ : {};
5457
+ const systemData = fill.systemData ?? {};
5458
+ const requestName = String(payload.requestName ?? '').trim();
5459
+ const requestLevel = String(systemData.requestLevel ?? '').trim();
5460
+ const secLevel = String(systemData.secLevel ?? '').trim();
5461
+ const remark = String(systemData.remark ?? '').trim();
5462
+ const envelope = await commandCreateRequest({
5463
+ context: contextId,
5464
+ data,
5465
+ action,
5466
+ // ★ 空串一律**不传键**:`createRequest` 的兜底链是 `args.x ?? sysDefaults.x ?? 句柄里的默认值`,
5467
+ // 而 `??` 挡不住空串 —— 传 `''` 会把后面的默认值全部短路掉(`request_name 不能为空` 就是这么来的)。
5468
+ ...(requestName ? { requestName } : {}),
5469
+ ...(requestLevel ? { requestLevel } : {}),
5470
+ ...(secLevel ? { secLevel } : {}),
5471
+ ...(remark ? { remark } : {}),
5472
+ verificationConfirmed: args.verificationConfirmed,
5473
+ }, timedEnv, { xiaoEViewLinkageFilter: true, apiSpans: spans });
5474
+ // 把耗时并回结果:与 `createRequest` 同一个函数、同一个口径,只是时间区间是整条命令
5475
+ // (含会话解析、工作流消歧、表单载入、智能填参、写库、校验)。
5476
+ return stampCommandTimings(envelope, { startedAt, spans, authMs });
5477
+ }
4785
5478
  function formatRemark(value) {
4786
5479
  const text = String(value ?? '').trim();
4787
5480
  if (!text)
@@ -4900,10 +5593,17 @@ function buildCreateBody(context, prepared, saved, src) {
4900
5593
  * - 多带 `operResultType`/`userSpecifiedNodeId`/`needAutoOpen`/`autoMaxDisplay`/`testParam`/
4901
5594
  * `seconedSubmit`(字段名本来就少一个 n,照抄不改)。
4902
5595
  */
4903
- function buildSubmitBody(context, prepared, view) {
5596
+ function buildSubmitBody(context, prepared, view,
5597
+ /**
5598
+ * 已经按默认样式包好的签字意见(见 `sign-input.ts`)。
5599
+ *
5600
+ * 不传则退回 `formatRemark` 的老形态 —— 只有"确实要提交、且内容非空"的那一档才会传,
5601
+ * 空意见与降级路径仍是 `_weaverMte_`,与历史完全一致。
5602
+ */
5603
+ wrappedRemark) {
4904
5604
  const runtime = context.runtime;
4905
5605
  const requestId = String(runtime.request_id ?? '');
4906
- const remark = formatRemark(prepared.remark);
5606
+ const remark = wrappedRemark ?? formatRemark(prepared.remark);
4907
5607
  return {
4908
5608
  operResultType: 'SUCCESS_RELOAD_CURRENT_PAGE',
4909
5609
  userSpecifiedNodeId: '0',
@@ -5151,13 +5851,18 @@ async function loadCreateSubmitContext(env, context, requestLevel) {
5151
5851
  * 连接中断同样按"提交结果不确定"如实回报;**认证错误照规则原样抛**,不降级成数据结论。
5152
5852
  */
5153
5853
  async function submitCreatedRequest(env, context, prepared, view) {
5854
+ // 提交前先按租户配置把签字意见包成带默认字体/字号的形态(发起流程 ⇒ `isCreate:1` + `workflowId`)。
5855
+ // 取不到样式时 `resolveSignRemark` 内部已退回老形态,不会阻断提交。
5856
+ const signRemark = await resolveSignRemark(env, prepared.remark, {
5857
+ workflowId: String(context.workflow?.id ?? ''),
5858
+ });
5154
5859
  let response;
5155
5860
  try {
5156
5861
  response = await env.host.workflowRequest({
5157
5862
  method: 'POST',
5158
5863
  path: FORM_ENDPOINTS.flowSubmit,
5159
5864
  query: { requestId: String(context.runtime.request_id ?? '') },
5160
- body: buildSubmitBody(context, prepared, view),
5865
+ body: buildSubmitBody(context, prepared, view, signRemark),
5161
5866
  operation: 'workflow.createRequest',
5162
5867
  });
5163
5868
  }
@@ -5648,7 +6353,7 @@ async function commandEditPrepare(args, env) {
5648
6353
  });
5649
6354
  }
5650
6355
  const merged = mergeDetails(context.currentDetails || [], details);
5651
- const linkageDetails = await runFieldLinkage(env, { ...linkageContextFromPayload(context), requestId: context.requestId, formId: context.form_id, dataId: context.data_id, workflow: context.workflow, businessFields: context.businessFields }, entries, null, merged, true);
6356
+ const linkageDetails = await runFieldLinkage(env, { ...linkageContextFromPayload(context), requestId: context.requestId, formId: context.form_id, dataId: context.data_id, workflow: context.workflow, businessFields: context.businessFields }, entries, null, merged, true, context.currentDetails || []);
5652
6357
  const merged2 = mergeLinkageDetails(merged, linkageDetails);
5653
6358
  const attachmentFolderIds = _attachmentFolderIds(context.rawUploadFields || {}, context.attachConfigs || {}, fields, _mainValueMap(merged2));
5654
6359
  const folderPayload = Object.keys(attachmentFolderIds).length ? { attachmentFolderIds } : {};
@@ -5822,7 +6527,7 @@ const workflowCreateForm = {
5822
6527
  resource: 'workflow',
5823
6528
  method: 'createForm',
5824
6529
  action: 'workflow.createForm',
5825
- summary: '按工作流参数解析可发起的工作流并载入发起表单:loadBase/loadFrame/loadMinor/formLayout 合并为 runtime 与 frame,产出 context 句柄 + 字段清单 + fileUploadParams + 该工作流的发起偏好 + 本表单必填字段的历史参考值(不落业务数据)',
6530
+ summary: '按工作流参数解析可发起的工作流并载入发起表单:loadBase/loadFrame/loadMinor/formLayout 合并为 runtime 与 frame,产出 context 句柄 + 字段清单 + fileUploadParams + 该工作流的发起偏好。传了 userQuestion 时额外调智能体把意图算成 data 参数(此时不返回 historyRequestData / systemFields)',
5826
6531
  capability: 'create',
5827
6532
  risk: 'read',
5828
6533
  requiresConfirmation: false,
@@ -5830,9 +6535,16 @@ const workflowCreateForm = {
5830
6535
  workflow: {
5831
6536
  type: 'array',
5832
6537
  items: { type: 'string' },
5833
- description: '工作流条件:支持 "名称(ID)"、名称(可传多个,用英文逗号分隔,如 "报销,报销流程")、纯数字 ID;按当前用户**可发起**的工作流模糊匹配;0 命中或多命中且无全名精确匹配时返回澄清(候选见 candidates),确认后由你按「名称(ID)」重发(只问用户完整名称或其它可用于检索的关键词,不要向用户索取 ID)',
6538
+ description: '工作流条件:支持 "名称(ID)"、名称(可传多个,用中英文逗号分隔,如 "请假,请假申请")、纯数字 ID;按当前用户**可发起**的工作流模糊匹配;未能唯一确定目标时返回澄清(候选见 candidates),确认后由你按「名称(ID)」重发(只问用户完整名称或其它可用于检索的关键词,不要向用户索取 ID)',
5834
6539
  },
5835
6540
  relatesTo: { type: 'string', description: '可选关联对象 id(仅透传,不影响表单初始化)' },
6541
+ userQuestion: {
6542
+ type: 'string',
6543
+ description: '用户的填单意图(用一句话概括,可以是多轮对话汇总后的最终需求)。'
6544
+ + '**传了才启用智能填参**:命令会在返回前把它连同字段清单交给智能体,换回已填好的 data 参数'
6545
+ + '(此时不再返回 historyRequestData / systemFields)。'
6546
+ + '不传 = 关闭这条链路,按原口径返回 historyRequestData 等参考值(老调用方零改动)',
6547
+ },
5836
6548
  }, { required: ['workflow'] }),
5837
6549
  handler: (args, env) => commandCreateForm(args, env),
5838
6550
  };
@@ -5861,6 +6573,34 @@ const workflowCreateRequest = {
5861
6573
  }, { required: ['context', 'data', 'action'] }),
5862
6574
  handler: (args, env) => commandCreateRequest(args, env),
5863
6575
  };
6576
+ const workflowCreateRequestDirect = {
6577
+ name: 'workflow.createRequestDirect',
6578
+ resource: 'workflow',
6579
+ method: 'createRequestDirect',
6580
+ action: 'workflow.createRequestDirect',
6581
+ summary: '一步发起(直接给 workflow,不必先调 createForm 取句柄;仍按 requiresConfirmation 先取得用户确认):先按 createForm 解析工作流并载入表单、把 userQuestion 交给智能体算成 data,然后直接接着执行 createRequest(保存 → 创建 → 可选提交);智能体没给出可用 data 时按 data 为空继续。小e 环境不跑字段联动但照跑显示属性联动、只交可编辑字段。返回结果与 createRequest 一致,另带 durationMs(总耗时)、apiDurations(每接口耗时)与 authMs(登录取凭据耗时)',
6582
+ capability: 'create',
6583
+ risk: 'high-risk-write',
6584
+ requiresConfirmation: true,
6585
+ inputSchema: schemaOf({
6586
+ workflow: {
6587
+ type: 'array',
6588
+ items: { type: 'string' },
6589
+ description: '工作流条件:支持 "名称(ID)"、名称(可传多个,用英文逗号分隔)、纯数字 ID;语义与 createForm 的 workflow 完全一致(含澄清规则)',
6590
+ },
6591
+ userQuestion: {
6592
+ type: 'string',
6593
+ description: '用户的填单意图(一句话概括,可多轮汇总)。命令会把它交给智能体算出要填的字段值后直接发起;为空或智能体没给出可用结果时,按"一个字段都没填"继续发起',
6594
+ },
6595
+ action: {
6596
+ type: 'string',
6597
+ enum: ['draft', 'submit'],
6598
+ description: 'draft=仅保存草稿;submit=提交审批(必填缺失时不写库、一次性回报缺失项)',
6599
+ },
6600
+ verificationConfirmed: { type: 'boolean', description: '节点字段校验需用户确认时显式确认继续提交' },
6601
+ }, { required: ['workflow', 'userQuestion', 'action'] }),
6602
+ handler: (args, env) => commandCreateRequestDirect(args, env),
6603
+ };
5864
6604
  const workflowEditLoad = {
5865
6605
  name: 'workflow.editLoad',
5866
6606
  resource: 'workflow',
@@ -5910,6 +6650,7 @@ const workflowEditApply = {
5910
6650
  export const workflowFormOperations = [
5911
6651
  workflowCreateForm,
5912
6652
  workflowCreateRequest,
6653
+ workflowCreateRequestDirect,
5913
6654
  workflowEditLoad,
5914
6655
  workflowEditPrepare,
5915
6656
  workflowEditApply,