@changfenhuang/dsh-genui 0.11.3 → 0.12.0

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 (60) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +21 -6
  3. package/README.zh-CN.md +21 -6
  4. package/SKILL.md +18 -6
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +10 -9
  7. package/lib/client.js +31 -33
  8. package/lib/index.js +1060 -371
  9. package/lib/types/client/blocks/forms.d.ts +5 -6
  10. package/lib/types/client/blocks/state.d.ts +2 -11
  11. package/lib/types/client/dom-fence.d.ts +2 -1
  12. package/lib/types/client/file-address.d.ts +4 -0
  13. package/lib/types/client/file-link-context.d.ts +41 -0
  14. package/lib/types/client/file-link.d.ts +17 -0
  15. package/lib/types/client/i18n/en.d.ts +5 -1
  16. package/lib/types/client/inline.d.ts +1 -1
  17. package/lib/types/client/mermaid-colors.d.ts +2 -0
  18. package/lib/types/client/panel.d.ts +6 -1
  19. package/lib/types/client/spec.d.ts +8 -11
  20. package/lib/types/client/submission-registry.d.ts +51 -0
  21. package/lib/types/client/table-details.d.ts +20 -0
  22. package/lib/types/client/walk-spec.d.ts +3 -0
  23. package/lib/types/plugin/fence-feedback.d.ts +55 -12
  24. package/lib/types/plugin/index.d.ts +7 -3
  25. package/lib/types/shared/fence-repair.d.ts +19 -7
  26. package/package.json +1 -12
  27. package/src/client/GenuiBlock.module.css +100 -4
  28. package/src/client/GenuiBlock.tsx +7 -14
  29. package/src/client/artifact/ArtifactExport.module.css +34 -3
  30. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  31. package/src/client/blocks/charts.tsx +63 -14
  32. package/src/client/blocks/forms.tsx +26 -48
  33. package/src/client/blocks/render-node.tsx +11 -8
  34. package/src/client/blocks/state.ts +2 -12
  35. package/src/client/dom-fence.tsx +190 -50
  36. package/src/client/file-address.ts +37 -0
  37. package/src/client/file-link-context.tsx +114 -0
  38. package/src/client/file-link.ts +46 -0
  39. package/src/client/genui-runtime/diagnostics.ts +20 -0
  40. package/src/client/genui-runtime/normalize.ts +28 -9
  41. package/src/client/guard.ts +98 -153
  42. package/src/client/i18n/en.ts +5 -1
  43. package/src/client/i18n/zh.ts +5 -1
  44. package/src/client/index.tsx +23 -5
  45. package/src/client/inline.ts +28 -10
  46. package/src/client/mermaid-colors.ts +39 -0
  47. package/src/client/mermaid-core.ts +7 -3
  48. package/src/client/panel.tsx +18 -5
  49. package/src/client/spec.ts +8 -11
  50. package/src/client/submission-registry.ts +150 -0
  51. package/src/client/table-details.ts +151 -0
  52. package/src/client/walk-spec.ts +43 -0
  53. package/src/plugin/fence-feedback.ts +384 -53
  54. package/src/plugin/index.ts +20 -9
  55. package/src/plugin/tool.ts +8 -2
  56. package/src/shared/fence-repair.ts +345 -128
  57. package/tsdown.config.ts +3 -7
  58. package/lib/invariant.js +0 -19
  59. package/lib/types/plugin/invariant.d.ts +0 -15
  60. package/src/plugin/invariant.ts +0 -30
package/lib/index.js CHANGED
@@ -784,6 +784,188 @@ function isGenuiSpec(value) {
784
784
  return true;
785
785
  }
786
786
  //#endregion
787
+ //#region src/client/genui-runtime/limits.ts
788
+ /** Resource limits shared by GenUI repair, validation, and rendering. */
789
+ const GENUI_LIMITS = {
790
+ /** Maximum nesting depth of the component tree. */
791
+ maxDepth: 8,
792
+ /** Maximum total nodes across the whole spec. */
793
+ maxNodes: 200,
794
+ /** Maximum length of any plain string field. */
795
+ maxString: 2e3,
796
+ /** Maximum length of a `code` body. */
797
+ maxCode: 12e3,
798
+ /** Maximum length of a mermaid source. */
799
+ maxMermaid: 8e3,
800
+ /** Maximum `grid` columns. */
801
+ maxGridCols: 12,
802
+ /** Maximum `tabs` count. */
803
+ maxTabs: 12,
804
+ /** Maximum `accordion` items. */
805
+ maxAccordionItems: 24,
806
+ /** Maximum `list` items. */
807
+ maxListItems: 50,
808
+ /** Maximum `select`/`radio` options. */
809
+ maxOptions: 50,
810
+ /** Maximum `table` rows / columns. */
811
+ maxTableRows: 50,
812
+ maxTableCols: 12,
813
+ /** Maximum `chart` data points per series. */
814
+ maxChartPoints: 60,
815
+ /** Maximum `plot` series and per-series parameters. */
816
+ maxPlotSeries: 8,
817
+ maxPlotParams: 6,
818
+ /** Maximum `scene3d` meshes. */
819
+ maxMeshes: 5,
820
+ /** Maximum `quiz` options. */
821
+ maxQuizOptions: 8,
822
+ /** Maximum `steps` / `timeline` / `breadcrumb` / `keyvalue` entries. */
823
+ maxSteps: 24,
824
+ maxTimelineItems: 24,
825
+ maxBreadcrumbItems: 12,
826
+ maxKeyValuePairs: 24,
827
+ /** Maximum `file-tree` nesting. */
828
+ maxTreeDepth: 6,
829
+ /** Maximum `diagram` nodes / edges / zones / focal accents. */
830
+ maxDiagramNodes: 9,
831
+ maxDiagramEdges: 12,
832
+ maxDiagramZones: 3,
833
+ maxDiagramFocal: 2,
834
+ maxDiagramLabel: 14,
835
+ /** Maximum depth of an `echart` option object. */
836
+ maxEChartOptionDepth: 10,
837
+ /** Maximum length of any single array inside an `echart` option. */
838
+ maxEChartArrayLen: 500,
839
+ /** Maximum entries traversed while sanitizing an `echart` option. */
840
+ maxEChartOptionNodes: 2e3
841
+ };
842
+ //#endregion
843
+ //#region src/client/table-details.ts
844
+ /** 使用 table repair 相同的转换规则处理列和行。 */
845
+ function prepareTableRows(table) {
846
+ const declaredColumns = table.columns;
847
+ const columnsSpecified = Array.isArray(declaredColumns) && declaredColumns.length > 0;
848
+ let columns = repairColumnValues(columnsSpecified && Array.isArray(declaredColumns) && typeof declaredColumns[0] === "object" && declaredColumns[0] !== null ? declaredColumns.map(columnHeaderText) : declaredColumns);
849
+ let rawRows = table.rows !== void 0 ? table.rows : table.data;
850
+ if (Array.isArray(rawRows) && rawRows.length > 0 && typeof rawRows[0] === "object" && rawRows[0] !== null && !Array.isArray(rawRows[0])) {
851
+ const keys = Array.isArray(table.columns) && table.columns.length > 0 && typeof table.columns[0] === "object" && table.columns[0] !== null ? table.columns.map(columnKeyOf).filter((key) => key !== void 0) : Object.keys(rawRows[0]);
852
+ rawRows = rawRows.map((row) => keys.map((key) => cellText(row[key])));
853
+ }
854
+ let derived = null;
855
+ if (!columnsSpecified && Array.isArray(rawRows) && rawRows.length > 0 && Array.isArray(rawRows[0])) {
856
+ const grid = repairRows(rawRows);
857
+ const candidate = grid.length === 0 ? null : deriveTableColumns(grid);
858
+ if (candidate !== null && repairRows(candidate.rows).length === candidate.rows.length) {
859
+ derived = candidate;
860
+ columns = candidate.columns.map((column) => column.slice(0, 128));
861
+ }
862
+ }
863
+ if (columns.length === 0 && !Array.isArray(table.columns)) return null;
864
+ const rows = repairRows(derived === null ? rawRows : derived.rows);
865
+ if (!Array.isArray(rawRows)) return null;
866
+ return {
867
+ columns: columns.slice(0, GENUI_LIMITS.maxTableCols),
868
+ rows
869
+ };
870
+ }
871
+ /** 返回 repair 后可显示详情的 table 行。 */
872
+ function tableRowsForDetails(table) {
873
+ return prepareTableRows(table)?.rows ?? [];
874
+ }
875
+ /** 按照 table renderer 的规则识别分组标题行。 */
876
+ function isTableGroupHeaderRow(row, types) {
877
+ if (!Array.isArray(row) || !Array.isArray(types) || types[0] !== "group") return false;
878
+ return String(row[0] ?? "").trim() !== "" && row.slice(1).every((cell) => String(cell ?? "").trim() === "");
879
+ }
880
+ /** 判断详情索引对应的行是否可展开并显示。 */
881
+ function isTableDetailReachable(table, rowIndex) {
882
+ const rows = tableRowsForDetails(table);
883
+ return rowIndex >= 0 && rowIndex < rows.length && !isTableGroupHeaderRow(rows[rowIndex], table.types);
884
+ }
885
+ /** 按照 guard 接受的 object alias 规则修复 table 列。 */
886
+ function repairColumnValues(value) {
887
+ if (!Array.isArray(value)) return [];
888
+ const columns = [];
889
+ for (const item of value) {
890
+ if (columns.length >= GENUI_LIMITS.maxTableCols) break;
891
+ if (typeof item === "string") columns.push(item.slice(0, 128));
892
+ else if (item !== null && typeof item === "object") {
893
+ const column = item;
894
+ const label = typeof column.label === "string" ? column.label : typeof column.value === "string" ? column.value : typeof column.title === "string" ? column.title : JSON.stringify(item);
895
+ columns.push(label.slice(0, 128));
896
+ }
897
+ }
898
+ return columns;
899
+ }
900
+ /** 按照 table repair 的单元格规则裁剪并清理行。 */
901
+ function repairRows(value) {
902
+ if (!Array.isArray(value)) return [];
903
+ const rows = [];
904
+ for (const row of value) {
905
+ if (rows.length >= GENUI_LIMITS.maxTableRows) break;
906
+ if (!Array.isArray(row)) continue;
907
+ const cells = [];
908
+ for (const cell of row) {
909
+ if (cells.length >= GENUI_LIMITS.maxTableCols) break;
910
+ if (typeof cell === "string") cells.push(cell.slice(0, 256));
911
+ else if (typeof cell === "number" && Number.isFinite(cell)) cells.push(cell);
912
+ }
913
+ if (cells.length > 0) rows.push(cells);
914
+ }
915
+ return rows;
916
+ }
917
+ /** 从有效的首行表头生成 table 列。 */
918
+ function deriveTableColumns(rows) {
919
+ const header = rows[0];
920
+ if (header === void 0 || header.length === 0) return null;
921
+ const body = rows.slice(1);
922
+ if (body.length === 0) {
923
+ const columns = header.map((cell) => String(cell).trim());
924
+ return columns.every((column) => column !== "") ? {
925
+ columns,
926
+ rows: []
927
+ } : null;
928
+ }
929
+ if (body.every((row) => row.length === header.length)) return {
930
+ columns: header.map((cell) => String(cell).trim()),
931
+ rows: body
932
+ };
933
+ return {
934
+ columns: Array.from({ length: header.length }, (_unused, index) => `列${index + 1}`),
935
+ rows
936
+ };
937
+ }
938
+ /** 读取 object row 映射到列时使用的 key。 */
939
+ function columnKeyOf(value) {
940
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
941
+ const column = value;
942
+ for (const key of [
943
+ "key",
944
+ "dataIndex",
945
+ "title",
946
+ "label"
947
+ ]) if (typeof column[key] === "string" && column[key] !== "") return column[key];
948
+ }
949
+ /** 将 object row 中的值转换为 table 单元格,并保留列位置。 */
950
+ function cellText(value) {
951
+ if (typeof value === "string") return value;
952
+ if (typeof value === "number" && Number.isFinite(value)) return value;
953
+ if (value === null || value === void 0) return "";
954
+ return JSON.stringify(value);
955
+ }
956
+ /** 读取 object column 显示的标题。 */
957
+ function columnHeaderText(value) {
958
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return String(value);
959
+ const column = value;
960
+ for (const key of [
961
+ "title",
962
+ "label",
963
+ "key",
964
+ "dataIndex"
965
+ ]) if (typeof column[key] === "string" && column[key] !== "") return column[key];
966
+ return JSON.stringify(value);
967
+ }
968
+ //#endregion
787
969
  //#region src/client/genui-runtime/normalize.ts
788
970
  /** Deterministic GenUI alias and structural normalization. */
789
971
  function record$1(value) {
@@ -801,6 +983,15 @@ const STAT_METRIC_FIELDS = [
801
983
  "spark",
802
984
  "size"
803
985
  ];
986
+ const LIST_ITEM_ALIASES = {
987
+ label: "title",
988
+ name: "title",
989
+ description: "desc",
990
+ content: "desc",
991
+ text: "desc",
992
+ body: "desc",
993
+ detail: "desc"
994
+ };
804
995
  /**
805
996
  * Normalize one metric record of a stat group into a single-metric `stat`
806
997
  * node, or null when the entry is not a metric at all.
@@ -935,11 +1126,35 @@ function normalizeNode(value, path, warnings) {
935
1126
  if (isNode$1(child)) return normalizeNodeValue(child, `${path}.items[${index}]`);
936
1127
  if (Array.isArray(child) && child.length === 1 && typeof child[0] === "string") return child[0];
937
1128
  const holder = record$1(child);
938
- if (holder === void 0 || !("description" in holder)) return holder === void 0 ? child : { ...holder };
1129
+ if (holder === void 0) return child;
939
1130
  const normalizedHolder = { ...holder };
940
- const description = normalizedHolder.description;
941
- delete normalizedHolder.description;
942
- if (!("desc" in normalizedHolder) && typeof description === "string") normalizedHolder.desc = description;
1131
+ const itemPath = `${path}.items[${index}]`;
1132
+ for (const [alias, canonical] of Object.entries(LIST_ITEM_ALIASES)) {
1133
+ if (!(alias in normalizedHolder)) continue;
1134
+ const keptCanonical = canonical in normalizedHolder;
1135
+ if (!keptCanonical) normalizedHolder[canonical] = normalizedHolder[alias];
1136
+ delete normalizedHolder[alias];
1137
+ warnings.push({
1138
+ kind: "alias",
1139
+ path: `${itemPath}.${alias}`,
1140
+ type,
1141
+ field: alias,
1142
+ canonical,
1143
+ message: keptCanonical ? `${itemPath}.${alias} is ignored because canonical field '${canonical}' is present` : `${itemPath}.${alias} normalized/adopted as '${canonical}'`
1144
+ });
1145
+ }
1146
+ if (!("title" in normalizedHolder) && typeof normalizedHolder.desc === "string") {
1147
+ normalizedHolder.title = normalizedHolder.desc;
1148
+ delete normalizedHolder.desc;
1149
+ warnings.push({
1150
+ kind: "alias",
1151
+ path: `${itemPath}.desc`,
1152
+ type,
1153
+ field: "desc",
1154
+ canonical: "title",
1155
+ message: `${itemPath}.desc normalized/adopted as 'title'`
1156
+ });
1157
+ }
943
1158
  return normalizedHolder;
944
1159
  });
945
1160
  else if (type === "keyvalue" && Array.isArray(out.pairs)) {
@@ -979,6 +1194,17 @@ function normalizeNode(value, path, warnings) {
979
1194
  items: Array.isArray(holder.items) ? holder.items.map((child, childIndex) => normalizeNodeValue(child, `${path}.items[${index}].items[${childIndex}]`)) : holder.items
980
1195
  };
981
1196
  });
1197
+ else if (type === "table" && Array.isArray(out.details)) {
1198
+ const table = {
1199
+ columns: out.columns,
1200
+ rows: out.rows,
1201
+ types: out.types
1202
+ };
1203
+ out.details = out.details.map((detail, rowIndex) => {
1204
+ if (!Array.isArray(detail) || !isTableDetailReachable(table, rowIndex)) return detail;
1205
+ return detail.map((child, childIndex) => normalizeNodeValue(child, `${path}.details[${rowIndex}][${childIndex}]`));
1206
+ });
1207
+ }
982
1208
  return out;
983
1209
  }
984
1210
  /**
@@ -1057,6 +1283,16 @@ function visitNativeNodes(value, path, visit) {
1057
1283
  const holder = record(item);
1058
1284
  if (holder?.items !== void 0 && Array.isArray(holder.items)) holder.items.forEach((child, childIndex) => children(child, `${path}.items[${index}].items[${childIndex}]`));
1059
1285
  });
1286
+ else if (type === "table" && Array.isArray(value.details)) {
1287
+ const table = {
1288
+ columns: value.columns,
1289
+ rows: value.rows,
1290
+ types: value.types
1291
+ };
1292
+ value.details.forEach((detail, rowIndex) => {
1293
+ if (Array.isArray(detail) && isTableDetailReachable(table, rowIndex)) detail.forEach((child, childIndex) => children(child, `${path}.details[${rowIndex}][${childIndex}]`));
1294
+ });
1295
+ }
1060
1296
  }
1061
1297
  function pushUnknownField(warnings, path, field, type) {
1062
1298
  warnings.push({
@@ -1106,6 +1342,22 @@ function diagnoseUnknownGenuiFields(value) {
1106
1342
  pushUnknownField(warnings, path, field, node.type);
1107
1343
  }
1108
1344
  diagnoseNestedFields(node, path, definition, warnings);
1345
+ if (node.type === "list" && Array.isArray(node.items)) node.items.forEach((item, index) => {
1346
+ if (isNode(item)) return;
1347
+ const holder = record(item);
1348
+ if (holder === void 0) return;
1349
+ for (const field of Object.keys(holder)) {
1350
+ if (field === "title" || field === "desc") continue;
1351
+ const itemPath = `${path}.items[${index}]`;
1352
+ warnings.push({
1353
+ kind: "unknown-field",
1354
+ path: `${itemPath}.${field}`,
1355
+ message: `${itemPath}.${field}: list items render 'title' and 'desc' only — '${field}' is dropped`,
1356
+ type: "list",
1357
+ field
1358
+ });
1359
+ }
1360
+ });
1109
1361
  };
1110
1362
  if (Array.isArray(root.items) && !isComponentRoot(root)) {
1111
1363
  for (const field of Object.keys(root)) {
@@ -1121,61 +1373,121 @@ function diagnoseUnknownGenuiFields(value) {
1121
1373
  return warnings;
1122
1374
  }
1123
1375
  //#endregion
1124
- //#region src/client/genui-runtime/limits.ts
1125
- /** Resource limits shared by GenUI repair, validation, and rendering. */
1126
- const GENUI_LIMITS = {
1127
- /** Maximum nesting depth of the component tree. */
1128
- maxDepth: 8,
1129
- /** Maximum total nodes across the whole spec. */
1130
- maxNodes: 200,
1131
- /** Maximum length of any plain string field. */
1132
- maxString: 2e3,
1133
- /** Maximum length of a `code` body. */
1134
- maxCode: 12e3,
1135
- /** Maximum length of a mermaid source. */
1136
- maxMermaid: 8e3,
1137
- /** Maximum `grid` columns. */
1138
- maxGridCols: 12,
1139
- /** Maximum `tabs` count. */
1140
- maxTabs: 12,
1141
- /** Maximum `accordion` items. */
1142
- maxAccordionItems: 24,
1143
- /** Maximum `list` items. */
1144
- maxListItems: 50,
1145
- /** Maximum `select`/`radio` options. */
1146
- maxOptions: 50,
1147
- /** Maximum `table` rows / columns. */
1148
- maxTableRows: 50,
1149
- maxTableCols: 12,
1150
- /** Maximum `chart` data points per series. */
1151
- maxChartPoints: 60,
1152
- /** Maximum `plot` series and per-series parameters. */
1153
- maxPlotSeries: 8,
1154
- maxPlotParams: 6,
1155
- /** Maximum `scene3d` meshes. */
1156
- maxMeshes: 5,
1157
- /** Maximum `quiz` options. */
1158
- maxQuizOptions: 8,
1159
- /** Maximum `steps` / `timeline` / `breadcrumb` / `keyvalue` entries. */
1160
- maxSteps: 24,
1161
- maxTimelineItems: 24,
1162
- maxBreadcrumbItems: 12,
1163
- maxKeyValuePairs: 24,
1164
- /** Maximum `file-tree` nesting. */
1165
- maxTreeDepth: 6,
1166
- /** Maximum `diagram` nodes / edges / zones / focal accents. */
1167
- maxDiagramNodes: 9,
1168
- maxDiagramEdges: 12,
1169
- maxDiagramZones: 3,
1170
- maxDiagramFocal: 2,
1171
- maxDiagramLabel: 14,
1172
- /** Maximum depth of an `echart` option object. */
1173
- maxEChartOptionDepth: 10,
1174
- /** Maximum length of any single array inside an `echart` option. */
1175
- maxEChartArrayLen: 500,
1176
- /** Maximum entries traversed while sanitizing an `echart` option. */
1177
- maxEChartOptionNodes: 2e3
1178
- };
1376
+ //#region src/client/walk-spec.ts
1377
+ /** 遍历完整 GenUI 组件树,并提供每个组件在 spec 中的路径。 */
1378
+ function walkGenuiNodes(spec, visitor) {
1379
+ const walk = (nodes, path, depth) => {
1380
+ if (depth > GENUI_LIMITS.maxDepth) return;
1381
+ nodes.forEach((node, index) => visit(node, `${path}[${index}]`, depth));
1382
+ };
1383
+ const visit = (node, at, depth) => {
1384
+ if (depth > GENUI_LIMITS.maxDepth) return;
1385
+ visitor(node, at);
1386
+ if (depth >= GENUI_LIMITS.maxDepth) return;
1387
+ switch (node.type) {
1388
+ case "row":
1389
+ case "col":
1390
+ case "grid":
1391
+ case "card":
1392
+ walk(node.items, `${at}.items`, depth + 1);
1393
+ break;
1394
+ case "tabs":
1395
+ node.tabs.forEach((tab, tabIndex) => walk(tab.items, `${at}.tabs[${tabIndex}].items`, depth + 1));
1396
+ break;
1397
+ case "accordion":
1398
+ node.items.forEach((item, itemIndex) => walk(item.items, `${at}.items[${itemIndex}].items`, depth + 1));
1399
+ break;
1400
+ case "list":
1401
+ node.items.forEach((item, itemIndex) => {
1402
+ if (item !== null && typeof item === "object" && "type" in item) visit(item, `${at}.items[${itemIndex}]`, depth + 1);
1403
+ });
1404
+ break;
1405
+ case "table": node.details?.slice(0, tableRowsForDetails(node).length).forEach((detail, rowIndex) => {
1406
+ if (detail !== null && isTableDetailReachable(node, rowIndex)) walk(detail, `${at}.details[${rowIndex}]`, depth + 1);
1407
+ });
1408
+ }
1409
+ };
1410
+ walk(spec.items, "items", 0);
1411
+ }
1412
+ //#endregion
1413
+ //#region src/client/submission-registry.ts
1414
+ /** 从 spec 建立成员表,并诊断 submission key 和 submit.groups。 */
1415
+ function analyzeSubmissionRegistry(spec) {
1416
+ const members = /* @__PURE__ */ new Map();
1417
+ const paths = /* @__PURE__ */ new Map();
1418
+ const diagnostics = [];
1419
+ const submits = [];
1420
+ walkGenuiNodes(spec, (node, path) => {
1421
+ if (node.type === "submit") {
1422
+ if (node.groups !== void 0) submits.push({
1423
+ groups: node.groups,
1424
+ path
1425
+ });
1426
+ return;
1427
+ }
1428
+ let member;
1429
+ let keyPath;
1430
+ switch (node.type) {
1431
+ case "radio":
1432
+ if (node.group === void 0) return;
1433
+ member = {
1434
+ kind: "radio",
1435
+ key: node.group,
1436
+ label: node.label ?? node.group,
1437
+ options: node.options.slice(0, GENUI_LIMITS.maxOptions),
1438
+ ...node.answer === void 0 ? {} : { answer: node.answer },
1439
+ ...node.explanation === void 0 ? {} : { explanation: node.explanation }
1440
+ };
1441
+ keyPath = `${path}.group`;
1442
+ break;
1443
+ case "checkbox":
1444
+ if (node.group === void 0) return;
1445
+ member = {
1446
+ kind: "checkbox",
1447
+ key: node.group
1448
+ };
1449
+ keyPath = `${path}.group`;
1450
+ break;
1451
+ case "input":
1452
+ case "textarea":
1453
+ case "select":
1454
+ case "slider":
1455
+ if (node.id === void 0) return;
1456
+ member = {
1457
+ kind: "field",
1458
+ key: node.id,
1459
+ fieldType: node.type,
1460
+ secret: node.type === "input" && node.inputType === "password"
1461
+ };
1462
+ keyPath = `${path}.id`;
1463
+ break;
1464
+ default: return;
1465
+ }
1466
+ if (member === void 0) return;
1467
+ const existing = members.get(member.key);
1468
+ if (existing !== void 0) {
1469
+ if (existing.kind !== "checkbox" || member.kind !== "checkbox") diagnostics.push(`${keyPath} conflicts with ${paths.get(member.key)}: submission key '${member.key}' is already used`);
1470
+ return;
1471
+ }
1472
+ members.set(member.key, member);
1473
+ paths.set(member.key, keyPath);
1474
+ });
1475
+ for (const { groups, path } of submits) {
1476
+ const seen = /* @__PURE__ */ new Set();
1477
+ groups.forEach((key, index) => {
1478
+ const at = `${path}.groups[${index}]`;
1479
+ if (seen.has(key)) diagnostics.push(`${at}: duplicate submission member '${key}'`);
1480
+ seen.add(key);
1481
+ const member = members.get(key);
1482
+ if (member === void 0) diagnostics.push(`${at}: submit.groups references unknown submission member '${key}'`);
1483
+ else if (member.kind === "field" && member.secret) diagnostics.push(`${at}: submit.groups cannot require secret field '${key}'`);
1484
+ });
1485
+ }
1486
+ return {
1487
+ registry: { members },
1488
+ diagnostics
1489
+ };
1490
+ }
1179
1491
  //#endregion
1180
1492
  //#region src/client/genui-runtime/value-utils.ts
1181
1493
  /** Shared primitive sanitizers used by the GenUI guard. */
@@ -1235,6 +1547,46 @@ function opt(key, value) {
1235
1547
  return value === void 0 ? {} : { [key]: value };
1236
1548
  }
1237
1549
  //#endregion
1550
+ //#region src/client/file-link.ts
1551
+ /** Decode a local file destination and its optional first line fragment. */
1552
+ function parseFileLink(value) {
1553
+ if (value.length > 2048) return void 0;
1554
+ const hash = value.indexOf("#");
1555
+ const destination = hash < 0 ? value : value.slice(0, hash);
1556
+ if (destination.includes("?")) return void 0;
1557
+ let path;
1558
+ try {
1559
+ path = decodeURIComponent(destination);
1560
+ } catch {
1561
+ return;
1562
+ }
1563
+ if (path === "" || /[\u0000-\u001f\u007f]/.test(path) || /^[\\/]{2}/.test(path) || /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path)) return void 0;
1564
+ if (hash < 0) return { path };
1565
+ const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(value.slice(hash + 1));
1566
+ if (match === null) return void 0;
1567
+ const line = Number(match[1]);
1568
+ const end = match[2] === void 0 ? line : Number(match[2]);
1569
+ if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return void 0;
1570
+ return {
1571
+ path,
1572
+ line
1573
+ };
1574
+ }
1575
+ /** Classify a link destination while preserving the existing external URL policy. */
1576
+ function classifyLinkTarget(value) {
1577
+ const external = safeHref(value);
1578
+ if (external !== void 0) return {
1579
+ type: "external",
1580
+ href: external
1581
+ };
1582
+ if (typeof value !== "string") return { type: "invalid" };
1583
+ const file = parseFileLink(value.trim());
1584
+ return file === void 0 ? { type: "invalid" } : {
1585
+ type: "file",
1586
+ file
1587
+ };
1588
+ }
1589
+ //#endregion
1238
1590
  //#region src/client/guard.ts
1239
1591
  function fieldKindMatches(value, kind) {
1240
1592
  switch (kind) {
@@ -1479,10 +1831,11 @@ function repairNodeFields(value, ctx, depth) {
1479
1831
  case "link": {
1480
1832
  const label = str(v.label, GENUI_LIMITS.maxString);
1481
1833
  if (label === void 0) return null;
1834
+ const target = classifyLinkTarget(v.href);
1482
1835
  return {
1483
1836
  type: "link",
1484
1837
  label,
1485
- ...opt("href", safeHref(v.href))
1838
+ ...opt("href", target.type === "external" ? target.href : target.type === "file" ? String(v.href).trim() : void 0)
1486
1839
  };
1487
1840
  }
1488
1841
  case "image": {
@@ -1587,25 +1940,9 @@ function repairNodeFields(value, ctx, depth) {
1587
1940
  };
1588
1941
  }
1589
1942
  case "table": {
1590
- let rawCols = v.columns;
1591
- let rawRows = v.rows !== void 0 ? v.rows : v.data;
1592
- if (Array.isArray(rawCols) && rawCols.length > 0 && typeof rawCols[0] === "object" && rawCols[0] !== null) rawCols = rawCols.map((c) => columnHeaderText(c));
1593
- if (Array.isArray(rawRows) && rawRows.length > 0 && typeof rawRows[0] === "object" && rawRows[0] !== null && !Array.isArray(rawRows[0])) {
1594
- const keys = Array.isArray(v.columns) && v.columns.length > 0 && typeof v.columns[0] === "object" && v.columns[0] !== null ? v.columns.map((c) => columnKeyOf(c)).filter((k) => k !== void 0) : Object.keys(rawRows[0]);
1595
- rawRows = rawRows.map((row) => keys.map((k) => cellText(row[k])));
1596
- }
1597
- let derived = null;
1598
- if ((!Array.isArray(rawCols) || rawCols.length === 0) && Array.isArray(rawRows) && rawRows.length > 0 && Array.isArray(rawRows[0])) {
1599
- const grid = repairRows(rawRows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols);
1600
- const candidate = grid === void 0 || grid.length === 0 ? null : deriveTableColumns(grid);
1601
- if (candidate !== null && repairRows(candidate.rows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)?.length === candidate.rows.length) {
1602
- derived = candidate;
1603
- rawCols = candidate.columns;
1604
- }
1605
- }
1606
- const columns = repairStrings(rawCols, GENUI_LIMITS.maxTableCols, 128);
1607
- const rows = repairRows(derived === null ? rawRows : derived.rows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols);
1608
- if (columns === void 0 || rows === void 0) return null;
1943
+ const prepared = prepareTableRows(v);
1944
+ if (prepared === null) return null;
1945
+ const { columns, rows } = prepared;
1609
1946
  const rawTypes = Array.isArray(v.types) ? v.types : void 0;
1610
1947
  const types = rawTypes === void 0 ? void 0 : columns.map((_c, i) => {
1611
1948
  const raw = rawTypes[i];
@@ -1613,6 +1950,11 @@ function repairNodeFields(value, ctx, depth) {
1613
1950
  });
1614
1951
  const rawDetails = Array.isArray(v.details) ? v.details : void 0;
1615
1952
  const details = rawDetails === void 0 ? void 0 : rows.map((_row, i) => {
1953
+ if (!isTableDetailReachable({
1954
+ columns,
1955
+ rows,
1956
+ types
1957
+ }, i)) return null;
1616
1958
  const entry = repairItems(rawDetails[i], ctx, depth + 1);
1617
1959
  return entry.length === 0 ? null : entry;
1618
1960
  });
@@ -1918,71 +2260,21 @@ function repairListItems(v, cap, ctx, depth) {
1918
2260
  continue;
1919
2261
  }
1920
2262
  const o = obj(item);
1921
- const title = o === void 0 ? void 0 : str(o.title, GENUI_LIMITS.maxString);
1922
- if (title !== void 0) {
1923
- out.push({
1924
- title,
1925
- ...opt("desc", o === void 0 ? void 0 : str(o.desc, GENUI_LIMITS.maxString) ?? str(o.description, GENUI_LIMITS.maxString))
1926
- });
1927
- continue;
1928
- }
1929
2263
  if (o !== void 0 && typeof o.type === "string") {
1930
2264
  if (ctx.remaining <= 0) break;
1931
2265
  ctx.remaining -= 1;
1932
2266
  const node = repairNode(o, ctx, depth);
1933
2267
  if (node !== null) out.push(node);
2268
+ continue;
1934
2269
  }
2270
+ const title = o === void 0 ? void 0 : str(o.title, GENUI_LIMITS.maxString);
2271
+ if (title !== void 0) out.push({
2272
+ title,
2273
+ ...opt("desc", str(o?.desc, GENUI_LIMITS.maxString))
2274
+ });
1935
2275
  }
1936
2276
  return out;
1937
2277
  }
1938
- function repairRows(v, rowCap, colCap) {
1939
- if (!Array.isArray(v)) return void 0;
1940
- const out = [];
1941
- for (const row of v) {
1942
- if (out.length >= rowCap) break;
1943
- if (!Array.isArray(row)) continue;
1944
- const cells = [];
1945
- for (const cell of row) {
1946
- if (cells.length >= colCap) break;
1947
- if (typeof cell === "string") cells.push(cell.slice(0, 256));
1948
- else if (typeof cell === "number" && Number.isFinite(cell)) cells.push(cell);
1949
- }
1950
- if (cells.length > 0) out.push(cells);
1951
- }
1952
- return out;
1953
- }
1954
- /** Left-aligned, undecorated columns for a table whose rows came without one. */
1955
- function derivedColumnNames(count) {
1956
- return Array.from({ length: count }, (_unused, index) => `列${index + 1}`);
1957
- }
1958
- /**
1959
- * Derive `columns` for a table that shipped only rows, without inventing
1960
- * content: the leading cell array is adopted as the header row and removed
1961
- * from the body — the shape both JSON table dumps and DataFrame-shaped
1962
- * exports are meant to be read as. Returns null when no unambiguous
1963
- * derivation exists (ragged rows) so the caller keeps its existing
1964
- * drop-and-report behaviour instead of rendering a fabricated header.
1965
- */
1966
- function deriveTableColumns(rows) {
1967
- const header = rows[0];
1968
- if (header === void 0 || header.length === 0) return null;
1969
- const body = rows.slice(1);
1970
- if (body.length === 0) {
1971
- const columns = header.map((cell) => String(cell).trim());
1972
- return columns.every((column) => column !== "") ? {
1973
- columns,
1974
- rows: []
1975
- } : null;
1976
- }
1977
- if (body.every((row) => row.length === header.length)) return {
1978
- columns: header.map((cell) => String(cell).trim()),
1979
- rows: body
1980
- };
1981
- return {
1982
- columns: derivedColumnNames(header.length),
1983
- rows
1984
- };
1985
- }
1986
2278
  function repairChartData(v, cap) {
1987
2279
  if (!Array.isArray(v)) return void 0;
1988
2280
  const out = [];
@@ -2023,53 +2315,15 @@ function repairTabs(v, ctx, depth) {
2023
2315
  for (const tab of v) {
2024
2316
  if (out.length >= GENUI_LIMITS.maxTabs) break;
2025
2317
  const o = obj(tab);
2026
- const label = o === void 0 ? void 0 : str(o.label, 128);
2027
- if (label === void 0 || o === void 0) continue;
2028
- const rawItems = o.items !== void 0 ? o.items : o.content !== void 0 ? Array.isArray(o.content) ? o.content : [o.content] : void 0;
2029
- out.push({
2030
- label,
2031
- items: repairItems(rawItems, ctx, depth + 1)
2032
- });
2033
- }
2034
- return out;
2035
- }
2036
- /** Header text for an object-shaped table column ({title,key} antd style). */
2037
- function columnHeaderText(c) {
2038
- const o = obj(c);
2039
- if (o === void 0) return String(c);
2040
- for (const k of [
2041
- "title",
2042
- "label",
2043
- "key",
2044
- "dataIndex"
2045
- ]) {
2046
- const s = o[k];
2047
- if (typeof s === "string" && s !== "") return s;
2048
- }
2049
- return JSON.stringify(c);
2050
- }
2051
- /** Row key for an object-shaped column, mirroring columnHeaderText's order. */
2052
- function columnKeyOf(c) {
2053
- const o = obj(c);
2054
- if (o === void 0) return void 0;
2055
- for (const k of [
2056
- "key",
2057
- "dataIndex",
2058
- "title",
2059
- "label"
2060
- ]) {
2061
- const s = o[k];
2062
- if (typeof s === "string" && s !== "") return s;
2318
+ const label = o === void 0 ? void 0 : str(o.label, 128);
2319
+ if (label === void 0 || o === void 0) continue;
2320
+ const rawItems = o.items !== void 0 ? o.items : o.content !== void 0 ? Array.isArray(o.content) ? o.content : [o.content] : void 0;
2321
+ out.push({
2322
+ label,
2323
+ items: repairItems(rawItems, ctx, depth + 1)
2324
+ });
2063
2325
  }
2064
- }
2065
- /** Cell text for object-array rows: strings/finite numbers pass through,
2066
- * everything else stringifies so the column alignment is preserved
2067
- * (repairRows would drop null/undefined cells and shift the row). */
2068
- function cellText(v) {
2069
- if (typeof v === "string") return v;
2070
- if (typeof v === "number" && Number.isFinite(v)) return v;
2071
- if (v === null || v === void 0) return "";
2072
- return JSON.stringify(v);
2326
+ return out;
2073
2327
  }
2074
2328
  function repairPlotSeries(v, cap) {
2075
2329
  if (!Array.isArray(v)) return void 0;
@@ -2500,6 +2754,14 @@ function countGenuiNodes(value, cap = Number.POSITIVE_INFINITY) {
2500
2754
  const lo = obj(li);
2501
2755
  if (lo !== void 0 && typeof lo.type === "string") walk([lo]);
2502
2756
  }
2757
+ else if (v.type === "table" && Array.isArray(v.details)) {
2758
+ const rowCount = tableRowsForDetails(v).length;
2759
+ for (let rowIndex = 0; rowIndex < Math.min(v.details.length, rowCount); rowIndex++) {
2760
+ if (count >= cap) return;
2761
+ const detail = v.details[rowIndex];
2762
+ if (Array.isArray(detail) && isTableDetailReachable(v, rowIndex)) walk(detail);
2763
+ }
2764
+ }
2503
2765
  }
2504
2766
  };
2505
2767
  const root = obj(value);
@@ -2539,6 +2801,10 @@ function visitDeclaredGenuiNodes(value, cap, visit) {
2539
2801
  else if (v.type === "accordion" && Array.isArray(v.items)) for (let row = 0; row < v.items.length; row++) walkItemsOf(v.items[row], `${at}.items[${row}]`);
2540
2802
  else if ((v.type === "row" || v.type === "col" || v.type === "grid" || v.type === "card") && Array.isArray(v.items)) walk(v.items, `${at}.items`);
2541
2803
  else if (v.type === "list" && Array.isArray(v.items)) for (let row = 0; row < v.items.length; row++) walkNode(v.items[row], `${at}.items[${row}]`);
2804
+ else if (v.type === "table" && Array.isArray(v.details)) {
2805
+ const rowCount = tableRowsForDetails(v).length;
2806
+ for (let row = 0; row < Math.min(v.details.length, rowCount); row++) if (Array.isArray(v.details[row]) && isTableDetailReachable(v, row)) walk(v.details[row], `${at}.details[${row}]`);
2807
+ }
2542
2808
  }
2543
2809
  function walkItemsOf(holder, path) {
2544
2810
  const o = obj(holder);
@@ -2618,6 +2884,7 @@ function validateCanonicalGenuiSpec(value) {
2618
2884
  }
2619
2885
  };
2620
2886
  walk(v.items, 0, "items");
2887
+ if (errors.length === 0) errors.push(...analyzeSubmissionRegistry(v).diagnostics);
2621
2888
  const uniqueErrors = [...new Set(errors)];
2622
2889
  return {
2623
2890
  ok: uniqueErrors.length === 0,
@@ -2643,13 +2910,14 @@ function processGenuiSpec(value) {
2643
2910
  const renderedNativeCount = repaired === null ? 0 : countRenderedNativeGenuiNodes(repaired);
2644
2911
  const renderedTotalCount = repaired === null ? 0 : countGenuiNodes(repaired);
2645
2912
  const errors = validation.errors.filter((error) => !error.includes(": unknown type "));
2913
+ if (repaired !== null && errors.length === 0) errors.push(...analyzeSubmissionRegistry(repaired).diagnostics);
2646
2914
  if (declaredNativeCount > renderedNativeCount) errors.push(`repair dropped ${declaredNativeCount - renderedNativeCount} declared native node(s): declared ${declaredNativeCount}, rendered ${renderedNativeCount}`);
2647
2915
  return {
2648
2916
  value: normalized.value,
2649
2917
  normalized: normalized.value,
2650
2918
  repaired,
2651
2919
  spec: repaired,
2652
- errors,
2920
+ errors: [...new Set(errors)],
2653
2921
  warnings: [...normalized.warnings, ...diagnoseUnknownGenuiFields(normalized.value)],
2654
2922
  declaredCount: declaredNativeCount,
2655
2923
  renderedCount: renderedTotalCount,
@@ -2666,31 +2934,30 @@ function isIntentionalBudgetCut(processed) {
2666
2934
  function isRenderableProcess(processed) {
2667
2935
  return processed.spec !== null && (processed.errors.length === 0 || isIntentionalBudgetCut(processed));
2668
2936
  }
2669
- /** Longest declared-node path prefix a validation error points at. */
2670
- const DECLARED_NODE_PATH_RE = /^(items\[\d+\](?:\.(?:items\[\d+\]|tabs\[\d+\]\.items\[\d+\]))*)/;
2937
+ /** Longest node or aligned table-detail slot path a validation error points at. */
2938
+ const DECLARED_NODE_PATH_RE = /^(items\[\d+\](?:\.(?:items\[\d+\]|tabs\[\d+\]\.items\[\d+\]|details\[\d+\](?:\[\d+\])?))*)/;
2671
2939
  function errorNodePath(error) {
2672
2940
  const match = DECLARED_NODE_PATH_RE.exec(error);
2673
2941
  return match === null ? null : match[1] ?? null;
2674
2942
  }
2675
- /** Where the node at a declared path lives: its parent array and index. */
2943
+ /** Where the node or table-detail slot at a validation path lives. */
2676
2944
  function nodeSlotAt(root, path) {
2677
- const steps = [...path.matchAll(/(?:^|\.)(items|tabs)\[(\d+)\]/g)];
2678
- if (steps.length === 0 || steps[steps.length - 1][1] !== "items") return void 0;
2945
+ const steps = [...path.matchAll(/(?:^|\.)(items|tabs|details)\[(\d+)\]|\[(\d+)\]/g)];
2946
+ if (steps.length === 0 || steps[steps.length - 1][1] === "tabs") return void 0;
2679
2947
  let current = root;
2680
- for (let i = 0; i < steps.length - 1; i++) {
2948
+ for (let i = 0; i < steps.length; i++) {
2681
2949
  const step = steps[i];
2682
- const holder = obj(current);
2683
- const list = holder === void 0 ? void 0 : holder[step[1]];
2684
- current = Array.isArray(list) ? list[Number(step[2])] : void 0;
2950
+ const list = step[1] === void 0 ? current : obj(current)?.[step[1]];
2951
+ const index = Number(step[2] ?? step[3]);
2952
+ if (!Array.isArray(list) || index >= list.length) return void 0;
2953
+ if (i === steps.length - 1) return {
2954
+ array: list,
2955
+ index,
2956
+ preserveIndex: step[1] === "details"
2957
+ };
2958
+ current = list[index];
2685
2959
  if (current === void 0) return void 0;
2686
2960
  }
2687
- const holder = obj(current);
2688
- const list = holder === void 0 ? void 0 : holder.items;
2689
- if (!Array.isArray(list)) return void 0;
2690
- return {
2691
- array: list,
2692
- index: Number(steps[steps.length - 1][2])
2693
- };
2694
2961
  }
2695
2962
  /** Deep-clone a JSON value for pruning; null when it cannot round-trip. */
2696
2963
  function cloneJsonValue(value) {
@@ -2716,20 +2983,31 @@ function partialRepairGenuiSpec(processed) {
2716
2983
  if (isRenderableProcess(processed)) return processed.spec;
2717
2984
  if (processed.spec === null) return null;
2718
2985
  const root = obj(processed.value);
2719
- if (root === void 0 || isComponentRoot(root)) return null;
2720
- if (processed.declaredNativeCount <= 1) return null;
2986
+ if (root === void 0) return null;
2721
2987
  const paths = /* @__PURE__ */ new Set();
2988
+ const overhangPaths = /* @__PURE__ */ new Set();
2722
2989
  for (const error of processed.errors) {
2723
2990
  if (error.startsWith("spec exceeds ")) continue;
2724
2991
  const nodePath = errorNodePath(error);
2725
- if (nodePath !== null) paths.add(nodePath);
2992
+ if (nodePath === null) continue;
2993
+ if (error === `${nodePath}.details must not contain more entries than rows`) overhangPaths.add(nodePath);
2994
+ else paths.add(nodePath);
2726
2995
  }
2727
- if (paths.size === 0) return null;
2728
- const pruned = cloneJsonValue(processed.value);
2996
+ if (paths.size === 0 && overhangPaths.size === 0) return null;
2997
+ const pruned = cloneJsonValue(isComponentRoot(root) ? wrapSingleComponentRoot(root) : root);
2729
2998
  if (pruned === null) return null;
2730
2999
  const slots = [...paths].map((path) => nodeSlotAt(pruned, path)).filter((slot) => slot !== void 0);
3000
+ const overhangSlots = [...overhangPaths].map((path) => nodeSlotAt(pruned, path)).filter((slot) => slot !== void 0);
3001
+ for (const { array, index } of overhangSlots) {
3002
+ const table = obj(array[index]);
3003
+ if (table?.type === "table" && Array.isArray(table.details)) table.details.length = Math.min(table.details.length, tableRowsForDetails(table).length);
3004
+ }
2731
3005
  const byArray = /* @__PURE__ */ new Map();
2732
- for (const { array, index } of slots) {
3006
+ for (const { array, index, preserveIndex } of slots) {
3007
+ if (preserveIndex) {
3008
+ array[index] = null;
3009
+ continue;
3010
+ }
2733
3011
  const indexes = byArray.get(array);
2734
3012
  if (indexes === void 0) byArray.set(array, [index]);
2735
3013
  else indexes.push(index);
@@ -2913,6 +3191,24 @@ function validateNode(value, depth, at, errors, walk) {
2913
3191
  if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`);
2914
3192
  if (v.types !== void 0 && !Array.isArray(v.types)) errors.push(`${at}.types must be an array of column cell types`);
2915
3193
  if (v.details !== void 0 && !Array.isArray(v.details)) errors.push(`${at}.details must be an array aligned with rows`);
3194
+ if (Array.isArray(v.details)) {
3195
+ const table = {
3196
+ columns: v.columns,
3197
+ rows: v.rows,
3198
+ types: v.types
3199
+ };
3200
+ const rowCount = tableRowsForDetails(table).length;
3201
+ if (v.details.length > rowCount) errors.push(`${at}.details must not contain more entries than rows`);
3202
+ for (let i = 0; i < Math.min(v.details.length, rowCount); i++) {
3203
+ const detail = v.details[i];
3204
+ if (detail === null || !isTableDetailReachable(table, i)) continue;
3205
+ if (!Array.isArray(detail)) {
3206
+ errors.push(`${at}.details[${i}] must be an array or null`);
3207
+ continue;
3208
+ }
3209
+ walk(detail, depth + 1, `${at}.details[${i}]`);
3210
+ }
3211
+ }
2916
3212
  validateTableRows(v.rows, `${at}.rows`, errors);
2917
3213
  break;
2918
3214
  case "chart":
@@ -3015,12 +3311,12 @@ function validateNode(value, depth, at, errors, walk) {
3015
3311
  * with ASCII quotes (e.g. `对"别名路径"判定失败`), which makes JSON.parse
3016
3312
  * fail near that quote with "Expected ',' or ']'...".
3017
3313
  * 2. Trailing commas before `}` / `]` or at end of input.
3314
+ * 3. Valueless object keys immediately before a complete key-and-colon pair.
3018
3315
  *
3019
- * The state-machine scan walks the raw body tracking string-open state:
3020
- * - inside a string, a quote whose next non-space char is NOT one of `, ] } :`
3021
- * (or end of input) cannot legally close the string → escape it as `\"`;
3022
- * - a `,` whose next non-space char is `}` / `]` / end of input is a trailing
3023
- * comma → drop it.
3316
+ * A shared grammar-aware scan distinguishes object keys from values and
3317
+ * checks the continuation after a potential string terminator. Ambiguous
3318
+ * value quotes use bounded backtracking; trailing commas are dropped only
3319
+ * outside strings.
3024
3320
  *
3025
3321
  * Returns `{ text, repairs }` on success, or null when nothing needed fixing
3026
3322
  * or the body still does not parse (callers fall through to tier-2 / banner).
@@ -3030,61 +3326,323 @@ function repairFenceJson(raw) {
3030
3326
  JSON.parse(raw);
3031
3327
  return null;
3032
3328
  } catch {}
3329
+ return scanFenceJson(raw, false);
3330
+ }
3331
+ /** A fixed search budget prevents quote ambiguity from becoming exponential. */
3332
+ const MAX_QUOTE_ATTEMPTS = 32;
3333
+ const MAX_QUOTE_LOOKAHEAD = 4096;
3334
+ function isJsonSpace(ch) {
3335
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r";
3336
+ }
3337
+ /** Skip only complete, valueless object keys followed by a valued member. */
3338
+ function skipValuelessKeys(raw, start, limit = raw.length) {
3339
+ let cursor = start;
3340
+ let repairs = 0;
3341
+ while (cursor < limit && raw[cursor] === "\"") {
3342
+ const keyStart = cursor++;
3343
+ while (cursor < limit && raw[cursor] !== "\"") if (raw[cursor] === "\\") cursor += 2;
3344
+ else cursor++;
3345
+ if (cursor >= limit) return null;
3346
+ cursor++;
3347
+ try {
3348
+ JSON.parse(raw.slice(keyStart, cursor));
3349
+ } catch {
3350
+ return null;
3351
+ }
3352
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++;
3353
+ if (raw[cursor] === ":") return repairs > 0 ? {
3354
+ start: keyStart,
3355
+ repairs
3356
+ } : null;
3357
+ if (raw[cursor] !== ",") return null;
3358
+ repairs++;
3359
+ cursor++;
3360
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++;
3361
+ }
3362
+ return null;
3363
+ }
3364
+ /**
3365
+ * Quote closure is contextual: only keys may be followed by `:`, and a comma
3366
+ * must introduce the next member of the enclosing object/array. Walk closing
3367
+ * delimiters too, so a bracket in a quoted code example cannot end a value
3368
+ * when prose immediately follows it inside the enclosing JSON structure.
3369
+ * Lookahead has a fixed bound; inconclusive long continuations keep the
3370
+ * terminator interpretation and leave the final JSON.parse as the arbiter.
3371
+ */
3372
+ function quoteCanClose(raw, index, key, scopes, complete) {
3373
+ const limit = Math.min(raw.length, index + MAX_QUOTE_LOOKAHEAD);
3374
+ let cursor = index + 1;
3375
+ const skipSpace = () => {
3376
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++;
3377
+ };
3378
+ skipSpace();
3379
+ if (cursor >= limit) return true;
3380
+ if (key) return raw[cursor] === ":";
3381
+ let scopeIndex = scopes.length - 1;
3382
+ if (raw[cursor] === ":") return false;
3383
+ while (raw[cursor] === "}" || raw[cursor] === "]") {
3384
+ const scope = scopes[scopeIndex];
3385
+ if (scope === void 0) return false;
3386
+ if (raw[cursor] === scope.closer) scopeIndex--;
3387
+ else if (!complete) return false;
3388
+ cursor++;
3389
+ skipSpace();
3390
+ if (cursor >= limit) return true;
3391
+ if (scopeIndex < 0) return complete;
3392
+ }
3393
+ if (raw[cursor] !== ",") return false;
3394
+ const scope = scopes[scopeIndex];
3395
+ if (scope === void 0) return false;
3396
+ cursor++;
3397
+ skipSpace();
3398
+ if (cursor >= limit) return true;
3399
+ if (raw[cursor] === scope.closer) return true;
3400
+ if (scope.closer === "]") return /["[{tfn\-0-9]/.test(raw[cursor]);
3401
+ if (raw[cursor] !== "\"") return false;
3402
+ const valueless = skipValuelessKeys(raw, cursor, limit);
3403
+ if (valueless !== null) cursor = valueless.start;
3404
+ cursor++;
3405
+ while (cursor < limit) {
3406
+ if (raw[cursor] === "\\") {
3407
+ cursor += 2;
3408
+ continue;
3409
+ }
3410
+ if (raw[cursor] === "\"") {
3411
+ cursor++;
3412
+ skipSpace();
3413
+ return cursor >= limit || raw[cursor] === ":";
3414
+ }
3415
+ if (raw[cursor].charCodeAt(0) < 32) return false;
3416
+ cursor++;
3417
+ }
3418
+ return true;
3419
+ }
3420
+ /** Both tiers share the same string-aware scan and quote decisions. */
3421
+ function scanFenceCandidate(raw, complete, contentQuotes) {
3033
3422
  let out = "";
3423
+ const scopes = [];
3034
3424
  let inString = false;
3425
+ let key = false;
3426
+ let interiorQuote = false;
3035
3427
  let escaped = false;
3036
3428
  let repairs = 0;
3429
+ let rootEnd = 0;
3430
+ let rawRootEnd = 0;
3431
+ let invalidValue = false;
3432
+ const choices = [];
3433
+ const finishValue = () => {
3434
+ const scope = scopes[scopes.length - 1];
3435
+ if (scope !== void 0) scope.expecting = "comma";
3436
+ };
3037
3437
  for (let i = 0; i < raw.length; i++) {
3038
3438
  const ch = raw[i];
3039
3439
  if (escaped) {
3440
+ if (inString && ch === "\"") interiorQuote = true;
3040
3441
  out += ch;
3041
3442
  escaped = false;
3042
3443
  continue;
3043
3444
  }
3044
- if (inString && ch === "\\") {
3045
- out += ch;
3046
- escaped = true;
3047
- continue;
3048
- }
3049
- if (ch === "\"") {
3050
- if (!inString) {
3051
- inString = true;
3445
+ if (inString) {
3446
+ if (ch === "\\") {
3052
3447
  out += ch;
3448
+ escaped = true;
3053
3449
  continue;
3054
3450
  }
3055
- let j = i + 1;
3056
- while (j < raw.length && (raw[j] === " " || raw[j] === " " || raw[j] === "\n" || raw[j] === "\r")) j++;
3057
- const next = j < raw.length ? raw[j] : "";
3058
- if (next === "," || next === "]" || next === "}" || next === ":" || next === "") {
3451
+ if (ch !== "\"") {
3452
+ out += ch;
3453
+ continue;
3454
+ }
3455
+ let next = i + 1;
3456
+ while (next < raw.length && isJsonSpace(raw[next])) next++;
3457
+ const ordinaryEnd = !key && (!interiorQuote && ",}]:".includes(raw[next] ?? "\0") || scopes[scopes.length - 1]?.closer === "]" && raw[next] === ",");
3458
+ if (!contentQuotes.has(i) && (ordinaryEnd || quoteCanClose(raw, i, key, scopes, complete))) {
3059
3459
  inString = false;
3060
3460
  out += ch;
3461
+ const scope = scopes[scopes.length - 1];
3462
+ if (key) {
3463
+ if (scope !== void 0) scope.expecting = "colon";
3464
+ } else {
3465
+ finishValue();
3466
+ if (scope !== void 0 && interiorQuote) {
3467
+ if (choices.length === MAX_QUOTE_ATTEMPTS) choices.shift();
3468
+ choices.push(i);
3469
+ }
3470
+ }
3061
3471
  } else {
3062
3472
  out += "\\\"";
3473
+ interiorQuote = true;
3063
3474
  repairs++;
3064
3475
  }
3065
3476
  continue;
3066
3477
  }
3478
+ if (ch === "\"") {
3479
+ key = scopes[scopes.length - 1]?.expecting === "key";
3480
+ if (key) {
3481
+ const valueless = skipValuelessKeys(raw, i);
3482
+ if (valueless !== null) {
3483
+ repairs += valueless.repairs;
3484
+ i = valueless.start - 1;
3485
+ continue;
3486
+ }
3487
+ }
3488
+ interiorQuote = false;
3489
+ inString = true;
3490
+ out += ch;
3491
+ continue;
3492
+ }
3493
+ if (ch === "{" || ch === "[") {
3494
+ finishValue();
3495
+ scopes.push({
3496
+ closer: ch === "{" ? "}" : "]",
3497
+ expecting: ch === "{" ? "key" : "value"
3498
+ });
3499
+ out += ch;
3500
+ continue;
3501
+ }
3502
+ if (ch === "}" || ch === "]") {
3503
+ if (scopes[scopes.length - 1]?.closer === ch) {
3504
+ scopes.pop();
3505
+ out += ch;
3506
+ if (scopes.length === 0 && rootEnd === 0) {
3507
+ rootEnd = out.length;
3508
+ rawRootEnd = i + 1;
3509
+ }
3510
+ } else if (complete) repairs++;
3511
+ else out += ch;
3512
+ continue;
3513
+ }
3067
3514
  if (ch === ",") {
3068
3515
  let j = i + 1;
3069
- while (j < raw.length && (raw[j] === " " || raw[j] === " " || raw[j] === "\n" || raw[j] === "\r")) j++;
3070
- const next = j < raw.length ? raw[j] : "";
3071
- if (next === "}" || next === "]" || next === "") {
3516
+ while (j < raw.length && isJsonSpace(raw[j])) j++;
3517
+ if (j === raw.length || raw[j] === "}" || raw[j] === "]") {
3072
3518
  repairs++;
3073
3519
  continue;
3074
3520
  }
3521
+ const scope = scopes[scopes.length - 1];
3522
+ if (scope !== void 0) scope.expecting = scope.closer === "}" ? "key" : "value";
3523
+ } else if (ch === ":") {
3524
+ const scope = scopes[scopes.length - 1];
3525
+ if (scope !== void 0) scope.expecting = "value";
3526
+ } else if (!isJsonSpace(ch) && scopes[scopes.length - 1]?.expecting === "value") {
3527
+ let j = i + 1;
3528
+ while (j < raw.length && !isJsonSpace(raw[j]) && !"{}[],:\"".includes(raw[j])) j++;
3529
+ const token = raw.slice(i, j);
3530
+ if (!/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(token)) invalidValue = true;
3531
+ finishValue();
3532
+ out += token;
3533
+ i = j - 1;
3534
+ continue;
3075
3535
  }
3076
3536
  out += ch;
3077
3537
  }
3078
- if (repairs === 0) return null;
3079
- try {
3080
- JSON.parse(out);
3081
- return {
3082
- text: out,
3083
- repairs
3084
- };
3085
- } catch {
3086
- return null;
3538
+ const unfinishedString = inString;
3539
+ if (complete) {
3540
+ if (inString) {
3541
+ out += "\"";
3542
+ repairs++;
3543
+ }
3544
+ while (scopes.length > 0) {
3545
+ out += scopes.pop().closer;
3546
+ repairs++;
3547
+ }
3548
+ }
3549
+ return {
3550
+ text: out,
3551
+ repairs,
3552
+ rootEnd,
3553
+ rawRootEnd,
3554
+ choices,
3555
+ unfinishedString,
3556
+ invalidValue
3557
+ };
3558
+ }
3559
+ /** Find an authoritative, already-valid object/array prefix without repair. */
3560
+ function originalJsonPrefix(raw) {
3561
+ const scopes = [];
3562
+ let inString = false;
3563
+ let escaped = false;
3564
+ let started = false;
3565
+ for (let i = 0; i < raw.length; i++) {
3566
+ const ch = raw[i];
3567
+ if (escaped) {
3568
+ escaped = false;
3569
+ continue;
3570
+ }
3571
+ if (inString) {
3572
+ if (ch === "\\") escaped = true;
3573
+ else if (ch === "\"") inString = false;
3574
+ continue;
3575
+ }
3576
+ if (!started) {
3577
+ if (isJsonSpace(ch)) continue;
3578
+ if (ch !== "{" && ch !== "[") return null;
3579
+ started = true;
3580
+ }
3581
+ if (ch === "\"") {
3582
+ inString = true;
3583
+ continue;
3584
+ }
3585
+ if (ch === "{" || ch === "[") scopes.push(ch === "{" ? "}" : "]");
3586
+ else if (ch === "}" || ch === "]") {
3587
+ if (scopes.pop() !== ch) return null;
3588
+ if (scopes.length === 0) {
3589
+ const text = raw.slice(0, i + 1).trimEnd();
3590
+ try {
3591
+ JSON.parse(text);
3592
+ return text;
3593
+ } catch {
3594
+ return null;
3595
+ }
3596
+ }
3597
+ }
3598
+ }
3599
+ return null;
3600
+ }
3601
+ /**
3602
+ * Prefer real terminators; if the resulting whole body fails, try interpreting
3603
+ * a bounded number of ambiguous value quotes as content. Each attempt is an
3604
+ * iterative scan, not a recursive parser; no engine-specific error offset is
3605
+ * needed. Fixed attempts/lookahead give O(n) time and O(n) working storage.
3606
+ */
3607
+ function scanFenceJson(raw, complete) {
3608
+ const original = originalJsonPrefix(raw);
3609
+ if (original !== null) return complete ? {
3610
+ text: original,
3611
+ repairs: 1
3612
+ } : null;
3613
+ const pending = [[]];
3614
+ let prefix = null;
3615
+ for (let attempt = 0; attempt < MAX_QUOTE_ATTEMPTS && pending.length > 0; attempt++) {
3616
+ const forced = pending.pop();
3617
+ const scanned = scanFenceCandidate(raw, complete, new Set(forced));
3618
+ if (scanned.repairs > 0 && !(forced.length > 0 && scanned.unfinishedString)) try {
3619
+ JSON.parse(scanned.text);
3620
+ return {
3621
+ text: scanned.text,
3622
+ repairs: scanned.repairs
3623
+ };
3624
+ } catch {}
3625
+ if (complete && scanned.rootEnd > 0 && (prefix === null || scanned.rawRootEnd > prefix.rawEnd)) {
3626
+ const text = scanned.text.slice(0, scanned.rootEnd).trimEnd();
3627
+ try {
3628
+ JSON.parse(text);
3629
+ prefix = {
3630
+ text,
3631
+ repairs: scanned.repairs + 1,
3632
+ rawEnd: scanned.rawRootEnd
3633
+ };
3634
+ } catch {}
3635
+ }
3636
+ if (scanned.invalidValue) continue;
3637
+ const last = forced[forced.length - 1] ?? -1;
3638
+ for (const quote of scanned.choices) if (quote > last) pending.push([...forced, quote]);
3639
+ if (pending.length > MAX_QUOTE_ATTEMPTS) pending.splice(0, pending.length - MAX_QUOTE_ATTEMPTS);
3087
3640
  }
3641
+ if (prefix !== null) return {
3642
+ text: prefix.text,
3643
+ repairs: prefix.repairs
3644
+ };
3645
+ return null;
3088
3646
  }
3089
3647
  /**
3090
3648
  * Rewrite the "Tetris table" shape into legal JSON: the model closed the
@@ -3215,8 +3773,8 @@ function rewriteTetrisTableColumns(raw) {
3215
3773
  * client uses the host-provided fence source; the validate tool is by
3216
3774
  * definition pre-emission), so a streaming half can never flash premature UI.
3217
3775
  *
3218
- * ONE unified scan: the tier-1 fixes (quote escaping + trailing-comma drops)
3219
- * are folded into the same pass, so bodies that combine BOTH defect classes
3776
+ * One shared scan implementation folds the tier-1 fixes (quote escaping +
3777
+ * trailing-comma drops) into structural completion, so bodies with BOTH defects
3220
3778
  * (a trailing comma AND a missing closer) heal in one shot — the old
3221
3779
  * two-phase chain lost tier-1's partial work when its whole-body parse
3222
3780
  * failed, and re-scanning the raw text could not compose the repairs.
@@ -3240,91 +3798,7 @@ function completeFenceJson(raw) {
3240
3798
  repairs: scanned.repairs + tetris.repairs
3241
3799
  };
3242
3800
  }
3243
- let out = "";
3244
- const stack = [];
3245
- let inString = false;
3246
- let escaped = false;
3247
- let repairs = 0;
3248
- for (let i = 0; i < raw.length; i++) {
3249
- const ch = raw[i];
3250
- if (escaped) {
3251
- out += ch;
3252
- escaped = false;
3253
- continue;
3254
- }
3255
- if (inString) {
3256
- if (ch === "\\") {
3257
- out += ch;
3258
- escaped = true;
3259
- continue;
3260
- }
3261
- if (ch !== "\"") {
3262
- out += ch;
3263
- continue;
3264
- }
3265
- let j = i + 1;
3266
- while (j < raw.length && (raw[j] === " " || raw[j] === " " || raw[j] === "\n" || raw[j] === "\r")) j++;
3267
- const next = j < raw.length ? raw[j] : "";
3268
- if (next === "," || next === "]" || next === "}" || next === ":" || next === "") {
3269
- inString = false;
3270
- out += ch;
3271
- } else {
3272
- out += "\\\"";
3273
- repairs++;
3274
- }
3275
- continue;
3276
- }
3277
- if (ch === "\"") {
3278
- inString = true;
3279
- out += ch;
3280
- continue;
3281
- }
3282
- if (ch === "{") {
3283
- stack.push("}");
3284
- out += ch;
3285
- continue;
3286
- }
3287
- if (ch === "[") {
3288
- stack.push("]");
3289
- out += ch;
3290
- continue;
3291
- }
3292
- if (ch === "}" || ch === "]") {
3293
- if (stack[stack.length - 1] === ch) {
3294
- stack.pop();
3295
- out += ch;
3296
- } else repairs++;
3297
- continue;
3298
- }
3299
- if (ch === ",") {
3300
- let j = i + 1;
3301
- while (j < raw.length && (raw[j] === " " || raw[j] === " " || raw[j] === "\n" || raw[j] === "\r")) j++;
3302
- const next = j < raw.length ? raw[j] : "";
3303
- if (next === "}" || next === "]" || next === "") {
3304
- repairs++;
3305
- continue;
3306
- }
3307
- }
3308
- out += ch;
3309
- }
3310
- if (inString) {
3311
- out += "\"";
3312
- repairs++;
3313
- }
3314
- while (stack.length > 0) {
3315
- out += stack.pop();
3316
- repairs++;
3317
- }
3318
- if (repairs === 0) return null;
3319
- try {
3320
- JSON.parse(out);
3321
- return {
3322
- text: out,
3323
- repairs
3324
- };
3325
- } catch {
3326
- return null;
3327
- }
3801
+ return scanFenceJson(raw, true);
3328
3802
  }
3329
3803
  //#endregion
3330
3804
  //#region src/plugin/genui-diagnostic.ts
@@ -3737,6 +4211,7 @@ function formatProcessWarnings(processed) {
3737
4211
  const canonicalPath = `${separator < 0 ? "" : warning.path.slice(0, separator + 1)}${warning.canonical}`;
3738
4212
  return warning.message.includes("ignored") ? `warning=alias_ignored path=${warning.path} canonical=${canonicalPath}` : `warning=alias_normalized path=${warning.path} canonical=${canonicalPath}`;
3739
4213
  }
4214
+ if (warning.kind === "unknown-field") return `warning=unknown_field path=${JSON.stringify(warning.path)} detail=${JSON.stringify(warning.message)}`;
3740
4215
  return `warning=process detail=${JSON.stringify(warning.message)}`;
3741
4216
  });
3742
4217
  }
@@ -3831,7 +4306,7 @@ function createRenderUiTool() {
3831
4306
  * before emitting — turning "render a red banner after the fact" into
3832
4307
  * "verify before you send". Purely local: no LLM, no network, no DOM.
3833
4308
  */
3834
- const VALIDATE_DESCRIPTION = "Validate the JSON body of a ```dsh-ui fence BEFORE emitting it — use for non-trivial specs (≥3 nodes or containing a table); skip for trivial ones (≤2 nodes). Pass the exact JSON text you are about to put inside the fence as the \"spec\" argument (a string). Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. A warning=block_markdown requires the indicated replacement node and another validation call.";
4309
+ const VALIDATE_DESCRIPTION = "Validate the JSON body of a ```dsh-ui fence BEFORE emitting it — use for non-trivial specs (≥3 nodes or containing a table); skip for trivial ones (≤2 nodes). Pass the exact JSON text you are about to put inside the fence as the \"spec\" argument (a string). Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. A warning=unknown_field requires fixing the indicated path and another validation call. A warning=block_markdown requires the indicated replacement node and another validation call.";
3835
4310
  const VALIDATE_PARAMETERS = {
3836
4311
  type: "object",
3837
4312
  properties: { spec: {
@@ -3947,6 +4422,7 @@ function createValidateDshUiTool() {
3947
4422
  if (processed.spec !== null && processed.errors.length === 0) {
3948
4423
  const warnings = formatProcessWarnings(processed);
3949
4424
  const inlineWarnings = formatInlineContentWarnings(processed.spec);
4425
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some((warning) => warning.kind === "unknown-field");
3950
4426
  return `${validationProtocol([
3951
4427
  "status=invalid",
3952
4428
  "error=invalid_json",
@@ -3956,7 +4432,7 @@ function createValidateDshUiTool() {
3956
4432
  ...inlineWarnings,
3957
4433
  "repair=applied",
3958
4434
  `repair_count=${repaired.repairs}`,
3959
- inlineWarnings.length === 0 ? "next=emit_repaired_fence" : "next=fix_and_revalidate"
4435
+ requiresCorrection ? "next=fix_and_revalidate" : "next=emit_repaired_fence"
3960
4436
  ])}\nrepaired_json:\n\`\`\`\n${repaired.text}\n\`\`\``;
3961
4437
  }
3962
4438
  }
@@ -3988,12 +4464,13 @@ function createValidateDshUiTool() {
3988
4464
  }
3989
4465
  const warnings = formatProcessWarnings(processed);
3990
4466
  const inlineWarnings = formatInlineContentWarnings(processed.spec);
4467
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some((warning) => warning.kind === "unknown-field");
3991
4468
  return validationProtocol([
3992
4469
  "status=valid",
3993
4470
  `rendered=${processed.renderedCount}`,
3994
4471
  ...warnings,
3995
4472
  ...inlineWarnings,
3996
- inlineWarnings.length === 0 ? "next=emit_fence" : "next=fix_and_revalidate"
4473
+ requiresCorrection ? "next=fix_and_revalidate" : "next=emit_fence"
3997
4474
  ]);
3998
4475
  },
3999
4476
  presentCall() {
@@ -4236,9 +4713,9 @@ function fenceFailures(text) {
4236
4713
  }
4237
4714
  /** `null` when this fence renders; otherwise the reason it does not. */
4238
4715
  function fenceFailureDetail(fence) {
4239
- if (!fence.closed) return "error=unterminated_fence\nrequired=closing_fence";
4240
4716
  const resolution = resolveFence(fence.raw, { settled: true });
4241
4717
  if (resolution.spec !== null) return null;
4718
+ if (!fence.closed) return "error=unterminated_fence\nrequired=closing_fence";
4242
4719
  if (resolution.processed !== null) return droppedNodeFailure(resolution.processed, resolution.value)?.join("\n") ?? ["error=invalid_spec", ...resolution.processed.errors.map((error) => `diagnostic=${JSON.stringify(error)}`)].join("\n");
4243
4720
  return "error=invalid_json\nrepair=failed";
4244
4721
  }
@@ -4288,25 +4765,84 @@ function createFeedbackMessage(text, sessionFormatVersion) {
4288
4765
  return Object.freeze(message);
4289
4766
  }
4290
4767
  /**
4291
- * Decide whether this turn boundary should steer a fence correction — the pure
4292
- * core of the loop, so every bound (one per turn, one per fence, cancellation)
4293
- * is testable without a host.
4768
+ * Decide whether this turn boundary should steer a correction — the pure core of
4769
+ * the loop, so every bound (per-turn cap, per-fence ledger, cancellation) is
4770
+ * testable without a host.
4294
4771
  *
4295
- * @param input - reply text, turn identity, and the session's accounting.
4772
+ * The decision is driven by FORMAL events only: fences in the reply body, a
4773
+ * `validate_dsh_ui` call, a delivered body text or a successful `render_ui`
4774
+ * result. The reasoning block is
4775
+ * never read here — a draft inside the thinking block is not proof that the model
4776
+ * chose to deliver it, so it must not change any decision.
4777
+ *
4778
+ * @param input - reply text, turn identity, formal signals, and the accounting.
4296
4779
  * @returns the correction to send, or null when the loop must stay silent.
4297
4780
  */
4298
4781
  function planFenceFeedback(input) {
4299
4782
  if (input.aborted) return null;
4300
- if (input.lastCorrectedTurn === input.turn) return null;
4301
- if (input.text.trim() === "") return null;
4302
- const failures = fenceFailures(input.text).filter((failure) => !input.corrected.has(failure.fingerprint));
4303
- if (failures.length === 0) return null;
4304
- return {
4783
+ const used = input.correctionsTurn === input.turn ? input.correctionsThisTurn ?? 0 : 0;
4784
+ if (used >= 2) return null;
4785
+ const failures = fenceFailures(input.text).filter((failure) => !input.correctedSpec.has(failure.fingerprint));
4786
+ if (failures.length > 0) return {
4305
4787
  text: fenceCorrectionText(failures),
4306
4788
  fingerprints: failures.map((failure) => failure.fingerprint),
4307
- turn: input.turn
4789
+ turn: input.turn,
4790
+ kind: "render"
4791
+ };
4792
+ if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null;
4793
+ if (input.deliveryRemindedTurns?.has(input.turn) === true) return null;
4794
+ return {
4795
+ text: missingBodyCorrectionText(input.turn, used + 1, input.renderUiToolEnabled ?? true),
4796
+ fingerprints: [],
4797
+ turn: input.turn,
4798
+ kind: "delivery"
4308
4799
  };
4309
4800
  }
4801
+ /**
4802
+ * Correction for a validated GenUI turn that reached the boundary without any
4803
+ * formal delivery.
4804
+ *
4805
+ * @param turn - turn that reached the boundary.
4806
+ * @param attempt - correction number within the shared turn budget.
4807
+ * @param renderUiToolEnabled - whether the correction may recommend `render_ui`.
4808
+ * @returns the message text to steer into the running turn.
4809
+ */
4810
+ function missingBodyCorrectionText(turn, attempt = 1, renderUiToolEnabled = true) {
4811
+ return `${`${MARKER_PREFIX}turn-${turn}]\n\n[genui-fence-repair]\nstatus=nothing_delivered\nfences=0\nnext=emit_fence_in_body\nrepeat_rendered_content=false\nreply_language=conversation\n\n`}本轮尚未产生正式回答,也没有通过支持的通道交付结果${attempt <= 1 ? "" : `(第 ${attempt} 次提醒)`}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏${renderUiToolEnabled ? ",或明确调用 render_ui" : ""}。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`;
4812
+ }
4813
+ /**
4814
+ * 将 GenUI 回合中仅含 reasoning 的完整响应转换为宿主已有的可重试空响应错误。
4815
+ *
4816
+ * @param options - LLM stream waterfall 拦截的请求。
4817
+ * @param source - 本次请求的 provider stream。
4818
+ * @returns 原始 stream;仅符合条件的终止 stop 会被改写。
4819
+ */
4820
+ async function* retryReasoningOnlyGenuiStream(options, source) {
4821
+ const { EMPTY_RESPONSE_CODE, isAgentLoopRequest } = await import("@deepseek-ai/dsh-llm");
4822
+ if (!isAgentLoopRequest(options)) {
4823
+ yield* source;
4824
+ return;
4825
+ }
4826
+ let hasReasoningBlock = false;
4827
+ let hasOtherBlock = false;
4828
+ for await (const chunk of source) {
4829
+ if (chunk.type === "block-end") {
4830
+ if (chunk.block.type === "reasoning") hasReasoningBlock = true;
4831
+ else hasOtherBlock = true;
4832
+ }
4833
+ if (chunk.type === "finish" && chunk.reason.kind === "stop" && hasReasoningBlock && !hasOtherBlock) yield {
4834
+ type: "finish",
4835
+ reason: {
4836
+ kind: "error",
4837
+ failure: {
4838
+ message: "GenUI turn completed with reasoning only and no deliverable response",
4839
+ code: EMPTY_RESPONSE_CODE
4840
+ }
4841
+ }
4842
+ };
4843
+ else yield chunk;
4844
+ }
4845
+ }
4310
4846
  /** Text of one assistant message's text blocks, in order. */
4311
4847
  function textOfContent(content) {
4312
4848
  if (!Array.isArray(content)) return "";
@@ -4316,9 +4852,59 @@ function textOfContent(content) {
4316
4852
  return record.type === "text" && typeof record.text === "string" ? record.text : "";
4317
4853
  }).filter((part) => part !== "").join("\n");
4318
4854
  }
4319
- /** Fingerprints this loop already recorded inside a steered correction. */
4320
- function markersIn(text) {
4321
- const out = [];
4855
+ /** 读取 render_ui result protocol 中明确返回的 status。 */
4856
+ function renderResultStatus(content) {
4857
+ const lines = textOfContent(content).split(/\r?\n/u);
4858
+ if (lines[0]?.trim() !== "[genui-render]") return void 0;
4859
+ for (const line of lines.slice(1)) {
4860
+ if (line === "status=rendered") return "rendered";
4861
+ if (line === "status=invalid") return "invalid";
4862
+ }
4863
+ }
4864
+ /** 从 legacy wrapper 与 Session format v4 中提取 tool result 数据。 */
4865
+ function observedToolResult(message) {
4866
+ if (typeof message !== "object" || message === null) return null;
4867
+ const record = message;
4868
+ if (typeof record.toolCallId === "string") return {
4869
+ callId: record.toolCallId,
4870
+ isError: record.isError === true,
4871
+ content: record.content
4872
+ };
4873
+ if (!Array.isArray(record.content)) return null;
4874
+ const block = record.content.find((part) => typeof part === "object" && part !== null && part.type === "tool-result");
4875
+ if (typeof block?.toolCallId !== "string") return null;
4876
+ return {
4877
+ callId: block.toolCallId,
4878
+ isError: block.isError === true,
4879
+ content: block.content
4880
+ };
4881
+ }
4882
+ /** Tool whose SUCCESSFUL result is a formal delivery. */
4883
+ const DELIVERY_TOOL = "render_ui";
4884
+ /**
4885
+ * Whether one assistant message delivered a non-empty body text.
4886
+ *
4887
+ * Deliberately narrow: a non-empty text block. A `render_ui` call is decided
4888
+ * by its `tool/result`, not by the call appearing in a message — a call that
4889
+ * failed (or whose result has not arrived) is not a delivery. Other tools
4890
+ * (validate_dsh_ui, bash, …) may succeed without producing any answer, so
4891
+ * they are NOT counted as delivery.
4892
+ *
4893
+ * @param content - assistant content blocks.
4894
+ * @returns true when this message delivered a body text.
4895
+ */
4896
+ function deliveredBodyText(content) {
4897
+ if (!Array.isArray(content)) return false;
4898
+ return content.some((block) => {
4899
+ if (typeof block !== "object" || block === null) return false;
4900
+ const record = block;
4901
+ return record.type === "text" && typeof record.text === "string" && record.text.trim() !== "";
4902
+ });
4903
+ }
4904
+ /** 分类当前与 legacy correction message 中的 marker。 */
4905
+ function feedbackMarkersIn(text) {
4906
+ const renderFingerprints = [];
4907
+ const deliveryTurns = [];
4322
4908
  let cursor = 0;
4323
4909
  while (cursor < text.length) {
4324
4910
  const current = text.indexOf(MARKER_PREFIX, cursor);
@@ -4329,31 +4915,54 @@ function markersIn(text) {
4329
4915
  const index = useLegacy ? legacy : current;
4330
4916
  const end = text.indexOf("]", index + prefix.length);
4331
4917
  if (end < 0) break;
4332
- out.push(text.slice(index + prefix.length, end));
4918
+ const marker = text.slice(index + prefix.length, end);
4919
+ const turn = /^turn-(\d+)$/u.exec(marker);
4920
+ if (turn !== null) deliveryTurns.push(Number(turn[1]));
4921
+ else renderFingerprints.push(marker);
4333
4922
  cursor = end + 1;
4334
4923
  }
4335
- return out;
4924
+ return {
4925
+ renderFingerprints,
4926
+ deliveryTurns
4927
+ };
4928
+ }
4929
+ /** 恢复一条持久化 correction 消耗的 turn budget。 */
4930
+ function accountReplayedCorrection(state, turn) {
4931
+ if (state.correctionsTurn !== turn) {
4932
+ state.correctionsTurn = turn;
4933
+ state.correctionsThisTurn = 1;
4934
+ return;
4935
+ }
4936
+ state.correctionsThisTurn = Math.min(2, state.correctionsThisTurn + 1);
4336
4937
  }
4337
4938
  /** Identify this plugin's source across current and migrated session shapes. */
4338
4939
  function isFeedbackSource(source) {
4339
4940
  return source?.kind === FEEDBACK_SOURCE_KIND || source?.kind === "plugin" && source.plugin === "@changfenhuang/dsh-genui";
4340
4941
  }
4341
4942
  /**
4342
- * 根据插件配置启用围栏反馈流程。
4943
+ * 注册 GenUI 回合跟踪、宿主重试判定和可选的围栏修正流程。
4343
4944
  *
4344
- * @param ctx - the host context.
4345
- * @param enabled - the plugin config flag; the loop is inert when false.
4945
+ * @param ctx - 宿主 Context。
4946
+ * @param enabled - 是否启用同回合围栏修正。
4947
+ * @param renderUiToolEnabled - 是否已注册 `render_ui` 工具。
4346
4948
  */
4347
- function installFenceFeedback(ctx, enabled) {
4348
- if (!enabled) return;
4949
+ function installFenceFeedback(ctx, enabled, renderUiToolEnabled = true) {
4349
4950
  const sessions = /* @__PURE__ */ new Map();
4350
4951
  const stateOf = (sessionId) => {
4351
4952
  let state = sessions.get(sessionId);
4352
4953
  if (state === void 0) {
4353
4954
  state = {
4354
4955
  text: "",
4355
- corrected: /* @__PURE__ */ new Set(),
4356
- lastCorrectedTurn: void 0
4956
+ validatedThisTurn: false,
4957
+ isSubagent: false,
4958
+ deliveredThisTurn: false,
4959
+ pendingRenders: /* @__PURE__ */ new Set(),
4960
+ correctedSpec: /* @__PURE__ */ new Set(),
4961
+ deliveryRemindedTurns: /* @__PURE__ */ new Set(),
4962
+ correctionsThisTurn: 0,
4963
+ correctionsTurn: void 0,
4964
+ currentTurn: void 0,
4965
+ accountedCorrectionMessageIds: /* @__PURE__ */ new Set()
4357
4966
  };
4358
4967
  sessions.set(sessionId, state);
4359
4968
  }
@@ -4362,46 +4971,123 @@ function installFenceFeedback(ctx, enabled) {
4362
4971
  ctx.on("session/disposed", (session) => {
4363
4972
  sessions.delete(String(session.id));
4364
4973
  });
4365
- ctx.on("session/event", (session, event) => {
4974
+ const resetTurnState = (state, turn) => {
4975
+ state.text = "";
4976
+ state.validatedThisTurn = false;
4977
+ state.deliveredThisTurn = false;
4978
+ state.pendingRenders.clear();
4979
+ state.currentTurn = turn;
4980
+ };
4981
+ const observeEvent = (session, event) => {
4366
4982
  const sessionId = String(session.id);
4983
+ if (event.type === "turn/start") {
4984
+ const state = stateOf(sessionId);
4985
+ state.isSubagent = session.header.parentSession !== void 0;
4986
+ state.correctedSpec.clear();
4987
+ resetTurnState(state, event.data.turn);
4988
+ return;
4989
+ }
4367
4990
  if (event.type === "assistant/message") {
4368
- const text = textOfContent(event.data.message?.content);
4369
- if (extractDshUiFences(text).length === 0) {
4370
- const state = sessions.get(sessionId);
4371
- if (state !== void 0) state.text = "";
4372
- return;
4373
- }
4374
- stateOf(sessionId).text = text;
4991
+ const content = event.data.message?.content;
4992
+ const text = textOfContent(content);
4993
+ const state = stateOf(sessionId);
4994
+ state.isSubagent = session.header.parentSession !== void 0;
4995
+ state.text = extractDshUiFences(text).length > 0 ? text : "";
4996
+ state.deliveredThisTurn = state.deliveredThisTurn || deliveredBodyText(content);
4997
+ return;
4998
+ }
4999
+ if (event.type === "tool/call") {
5000
+ const data = event.data;
5001
+ const state = stateOf(sessionId);
5002
+ state.isSubagent = session.header.parentSession !== void 0;
5003
+ if (data.name === "validate_dsh_ui") state.validatedThisTurn = true;
5004
+ if (data.name === DELIVERY_TOOL && typeof data.callId === "string") state.pendingRenders.add(data.callId);
5005
+ return;
5006
+ }
5007
+ if (event.type === "tool/result") {
5008
+ const data = event.data;
5009
+ const result = observedToolResult(data.message);
5010
+ if (result === null) return;
5011
+ const state = stateOf(sessionId);
5012
+ state.isSubagent = session.header.parentSession !== void 0;
5013
+ if (!state.pendingRenders.delete(result.callId)) return;
5014
+ if (data.error === void 0 && result.isError !== true && renderResultStatus(result.content) === "rendered") state.deliveredThisTurn = true;
4375
5015
  return;
4376
5016
  }
4377
5017
  if (event.type !== "user/message") return;
4378
5018
  const data = event.data;
4379
5019
  if (isFeedbackSource(data.source)) {
4380
- const fingerprints = markersIn(textOfContent(data.content));
4381
- if (fingerprints.length === 0) return;
5020
+ const markers = feedbackMarkersIn(textOfContent(data.content));
5021
+ if (markers.renderFingerprints.length === 0 && markers.deliveryTurns.length === 0) return;
4382
5022
  const state = stateOf(sessionId);
4383
- for (const fingerprint of fingerprints) state.corrected.add(fingerprint);
5023
+ state.isSubagent = session.header.parentSession !== void 0;
5024
+ for (const fingerprint of markers.renderFingerprints) state.correctedSpec.add(fingerprint);
5025
+ for (const turn of markers.deliveryTurns) state.deliveryRemindedTurns.add(turn);
5026
+ if (typeof data.id === "string" && !state.accountedCorrectionMessageIds.has(data.id)) {
5027
+ state.accountedCorrectionMessageIds.add(data.id);
5028
+ const turn = markers.deliveryTurns[0] ?? state.currentTurn;
5029
+ if (turn !== void 0) accountReplayedCorrection(state, turn);
5030
+ }
4384
5031
  return;
4385
5032
  }
5033
+ if (data.source?.kind !== "user") return;
4386
5034
  const state = sessions.get(sessionId);
4387
- if (state !== void 0) state.text = "";
5035
+ if (state !== void 0) {
5036
+ state.isSubagent = session.header.parentSession !== void 0;
5037
+ resetTurnState(state, state.currentTurn);
5038
+ }
5039
+ };
5040
+ const restoreHistory = (session) => {
5041
+ if (sessions.has(String(session.id))) return;
5042
+ for (const event of session.snapshotEvents()) observeEvent(session, event);
5043
+ };
5044
+ ctx.on("session/event", (session, event) => {
5045
+ restoreHistory(session);
5046
+ observeEvent(session, event);
4388
5047
  });
5048
+ ctx.on("session/created", restoreHistory);
5049
+ const restoreSessions = (sessionCtx) => {
5050
+ const store = sessionCtx.reflect.get("sessions");
5051
+ if (store === void 0) return;
5052
+ for (const session of store.list()) restoreHistory(session);
5053
+ };
5054
+ restoreSessions(ctx);
5055
+ ctx.inject(["sessions"], restoreSessions);
5056
+ ctx.on("llm/stream", (options, next) => {
5057
+ if (options.sessionId === void 0 || options.purpose !== void 0) return next();
5058
+ const state = sessions.get(String(options.sessionId));
5059
+ if (state?.validatedThisTurn !== true || state.isSubagent) return next();
5060
+ return retryReasoningOnlyGenuiStream(options, next());
5061
+ }, { global: true });
4389
5062
  ctx.on("agent/turn-stopping", ({ agent, turn, signal }) => {
5063
+ if (!enabled) return;
4390
5064
  if (agent.session.header.parentSession !== void 0) return;
4391
5065
  const state = sessions.get(String(agent.session.id));
4392
5066
  if (state === void 0) return;
5067
+ state.currentTurn = turn;
5068
+ if (state.pendingRenders.size > 0) return;
5069
+ const usedThisTurn = state.correctionsTurn === turn ? state.correctionsThisTurn : 0;
4393
5070
  const plan = planFenceFeedback({
4394
5071
  text: state.text,
4395
5072
  turn,
4396
- lastCorrectedTurn: state.lastCorrectedTurn,
4397
- corrected: state.corrected,
4398
- aborted: signal.aborted
5073
+ correctedSpec: state.correctedSpec,
5074
+ deliveryRemindedTurns: state.deliveryRemindedTurns,
5075
+ validatedThisTurn: state.validatedThisTurn,
5076
+ deliveredThisTurn: state.deliveredThisTurn,
5077
+ aborted: signal.aborted,
5078
+ correctionsThisTurn: usedThisTurn,
5079
+ correctionsTurn: state.correctionsTurn,
5080
+ renderUiToolEnabled
4399
5081
  });
4400
5082
  if (plan === null) return;
4401
- for (const fingerprint of plan.fingerprints) state.corrected.add(fingerprint);
4402
- state.lastCorrectedTurn = plan.turn;
5083
+ for (const fingerprint of plan.fingerprints) state.correctedSpec.add(fingerprint);
5084
+ if (plan.kind === "delivery") state.deliveryRemindedTurns.add(plan.turn);
5085
+ state.correctionsTurn = plan.turn;
5086
+ state.correctionsThisTurn = usedThisTurn + 1;
4403
5087
  try {
4404
- agent.steer(createFeedbackMessage(plan.text, agent.session.header.version));
5088
+ const message = createFeedbackMessage(plan.text, agent.session.header.version);
5089
+ state.accountedCorrectionMessageIds.add(message.id);
5090
+ agent.steer(message);
4405
5091
  } catch (error) {
4406
5092
  ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`);
4407
5093
  }
@@ -4463,6 +5149,7 @@ async function serveGenuiAsset(req, res) {
4463
5149
  res.end();
4464
5150
  }
4465
5151
  }
5152
+ const RENDER_UI_TOOL_GUIDANCE = "- Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。";
4466
5153
  /** The fence language description injected into every assembled system prompt.
4467
5154
  * Deliberately slim: the `genui` skill carries the full component→field
4468
5155
  * mapping; this section keeps only the contract that must always be
@@ -4499,12 +5186,13 @@ Rules:
4499
5186
  - JSON 严格:坏组件被丢弃,坏围栏变代码块;≥3 节点或含 table 时调 validate_dsh_ui,按诊断修改并重验;小围栏字段存疑也先验证。
4500
5187
  - warning=block_markdown:按 replacement 改写并重验。
4501
5188
  - 规模: ≤200 节点、嵌套≤8 层(超出被截断);一条回答 3–8 个组件,一个主题一个主组件;3D mesh 1–5;plot 给合理 xMin/xMax。
4502
- - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互组件带 "action":"name",交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
4503
- - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;重渲染相同内容保留,新内容重置。
5189
+ - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
5190
+ - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
4504
5191
  - 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
4505
5192
  - Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
4506
- - Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片(交付物型界面用);围栏用于回答内联 UI。
4507
- - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并(同标签 tabs 追加/新标签加入/尾部追加);上限 200 节点/200 次追加,满了发 replace 重建。面板组件来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认,不解释、不用普通围栏。`;
5193
+ ${RENDER_UI_TOOL_GUIDANCE}
5194
+ - 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
5195
+ - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`;
4508
5196
  /**
4509
5197
  * Register the GenUI output-language section and the render_ui tool.
4510
5198
  * @param ctx - cordis context.
@@ -4557,15 +5245,16 @@ function bundledSkillProvider() {
4557
5245
  };
4558
5246
  }
4559
5247
  function apply(ctx, config) {
5248
+ const renderUiToolEnabled = config?.renderUiTool !== false;
4560
5249
  ctx.systemPrompt.section({
4561
5250
  name: "genui:fence",
4562
5251
  order: ctx.systemPrompt.getSectionOrder("STRUCTURED_OUTPUT"),
4563
- text: GENUI_SECTION_TEXT
5252
+ text: renderUiToolEnabled ? GENUI_SECTION_TEXT : GENUI_SECTION_TEXT.replace(`${RENDER_UI_TOOL_GUIDANCE}\n`, "")
4564
5253
  });
4565
- installFenceFeedback(ctx, config?.fenceFeedback !== false);
5254
+ installFenceFeedback(ctx, config?.fenceFeedback !== false, renderUiToolEnabled);
4566
5255
  ctx.inject(["tools"], (toolsCtx) => {
4567
5256
  toolsCtx.effect(function* () {
4568
- yield toolsCtx.tools.register(createRenderUiTool());
5257
+ if (renderUiToolEnabled) yield toolsCtx.tools.register(createRenderUiTool());
4569
5258
  yield toolsCtx.tools.register(createValidateDshUiTool());
4570
5259
  }, "dsh-genui: model tools");
4571
5260
  });