@changfenhuang/dsh-genui 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. */
@@ -1587,25 +1899,9 @@ function repairNodeFields(value, ctx, depth) {
1587
1899
  };
1588
1900
  }
1589
1901
  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;
1902
+ const prepared = prepareTableRows(v);
1903
+ if (prepared === null) return null;
1904
+ const { columns, rows } = prepared;
1609
1905
  const rawTypes = Array.isArray(v.types) ? v.types : void 0;
1610
1906
  const types = rawTypes === void 0 ? void 0 : columns.map((_c, i) => {
1611
1907
  const raw = rawTypes[i];
@@ -1613,6 +1909,11 @@ function repairNodeFields(value, ctx, depth) {
1613
1909
  });
1614
1910
  const rawDetails = Array.isArray(v.details) ? v.details : void 0;
1615
1911
  const details = rawDetails === void 0 ? void 0 : rows.map((_row, i) => {
1912
+ if (!isTableDetailReachable({
1913
+ columns,
1914
+ rows,
1915
+ types
1916
+ }, i)) return null;
1616
1917
  const entry = repairItems(rawDetails[i], ctx, depth + 1);
1617
1918
  return entry.length === 0 ? null : entry;
1618
1919
  });
@@ -1918,71 +2219,21 @@ function repairListItems(v, cap, ctx, depth) {
1918
2219
  continue;
1919
2220
  }
1920
2221
  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
2222
  if (o !== void 0 && typeof o.type === "string") {
1930
2223
  if (ctx.remaining <= 0) break;
1931
2224
  ctx.remaining -= 1;
1932
2225
  const node = repairNode(o, ctx, depth);
1933
2226
  if (node !== null) out.push(node);
2227
+ continue;
1934
2228
  }
2229
+ const title = o === void 0 ? void 0 : str(o.title, GENUI_LIMITS.maxString);
2230
+ if (title !== void 0) out.push({
2231
+ title,
2232
+ ...opt("desc", str(o?.desc, GENUI_LIMITS.maxString))
2233
+ });
1935
2234
  }
1936
2235
  return out;
1937
2236
  }
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
2237
  function repairChartData(v, cap) {
1987
2238
  if (!Array.isArray(v)) return void 0;
1988
2239
  const out = [];
@@ -2033,44 +2284,6 @@ function repairTabs(v, ctx, depth) {
2033
2284
  }
2034
2285
  return out;
2035
2286
  }
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;
2063
- }
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);
2073
- }
2074
2287
  function repairPlotSeries(v, cap) {
2075
2288
  if (!Array.isArray(v)) return void 0;
2076
2289
  const out = [];
@@ -2500,6 +2713,14 @@ function countGenuiNodes(value, cap = Number.POSITIVE_INFINITY) {
2500
2713
  const lo = obj(li);
2501
2714
  if (lo !== void 0 && typeof lo.type === "string") walk([lo]);
2502
2715
  }
2716
+ else if (v.type === "table" && Array.isArray(v.details)) {
2717
+ const rowCount = tableRowsForDetails(v).length;
2718
+ for (let rowIndex = 0; rowIndex < Math.min(v.details.length, rowCount); rowIndex++) {
2719
+ if (count >= cap) return;
2720
+ const detail = v.details[rowIndex];
2721
+ if (Array.isArray(detail) && isTableDetailReachable(v, rowIndex)) walk(detail);
2722
+ }
2723
+ }
2503
2724
  }
2504
2725
  };
2505
2726
  const root = obj(value);
@@ -2539,6 +2760,10 @@ function visitDeclaredGenuiNodes(value, cap, visit) {
2539
2760
  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
2761
  else if ((v.type === "row" || v.type === "col" || v.type === "grid" || v.type === "card") && Array.isArray(v.items)) walk(v.items, `${at}.items`);
2541
2762
  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}]`);
2763
+ else if (v.type === "table" && Array.isArray(v.details)) {
2764
+ const rowCount = tableRowsForDetails(v).length;
2765
+ 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}]`);
2766
+ }
2542
2767
  }
2543
2768
  function walkItemsOf(holder, path) {
2544
2769
  const o = obj(holder);
@@ -2618,6 +2843,7 @@ function validateCanonicalGenuiSpec(value) {
2618
2843
  }
2619
2844
  };
2620
2845
  walk(v.items, 0, "items");
2846
+ if (errors.length === 0) errors.push(...analyzeSubmissionRegistry(v).diagnostics);
2621
2847
  const uniqueErrors = [...new Set(errors)];
2622
2848
  return {
2623
2849
  ok: uniqueErrors.length === 0,
@@ -2643,13 +2869,14 @@ function processGenuiSpec(value) {
2643
2869
  const renderedNativeCount = repaired === null ? 0 : countRenderedNativeGenuiNodes(repaired);
2644
2870
  const renderedTotalCount = repaired === null ? 0 : countGenuiNodes(repaired);
2645
2871
  const errors = validation.errors.filter((error) => !error.includes(": unknown type "));
2872
+ if (repaired !== null && errors.length === 0) errors.push(...analyzeSubmissionRegistry(repaired).diagnostics);
2646
2873
  if (declaredNativeCount > renderedNativeCount) errors.push(`repair dropped ${declaredNativeCount - renderedNativeCount} declared native node(s): declared ${declaredNativeCount}, rendered ${renderedNativeCount}`);
2647
2874
  return {
2648
2875
  value: normalized.value,
2649
2876
  normalized: normalized.value,
2650
2877
  repaired,
2651
2878
  spec: repaired,
2652
- errors,
2879
+ errors: [...new Set(errors)],
2653
2880
  warnings: [...normalized.warnings, ...diagnoseUnknownGenuiFields(normalized.value)],
2654
2881
  declaredCount: declaredNativeCount,
2655
2882
  renderedCount: renderedTotalCount,
@@ -2666,31 +2893,30 @@ function isIntentionalBudgetCut(processed) {
2666
2893
  function isRenderableProcess(processed) {
2667
2894
  return processed.spec !== null && (processed.errors.length === 0 || isIntentionalBudgetCut(processed));
2668
2895
  }
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+\]))*)/;
2896
+ /** Longest node or aligned table-detail slot path a validation error points at. */
2897
+ const DECLARED_NODE_PATH_RE = /^(items\[\d+\](?:\.(?:items\[\d+\]|tabs\[\d+\]\.items\[\d+\]|details\[\d+\](?:\[\d+\])?))*)/;
2671
2898
  function errorNodePath(error) {
2672
2899
  const match = DECLARED_NODE_PATH_RE.exec(error);
2673
2900
  return match === null ? null : match[1] ?? null;
2674
2901
  }
2675
- /** Where the node at a declared path lives: its parent array and index. */
2902
+ /** Where the node or table-detail slot at a validation path lives. */
2676
2903
  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;
2904
+ const steps = [...path.matchAll(/(?:^|\.)(items|tabs|details)\[(\d+)\]|\[(\d+)\]/g)];
2905
+ if (steps.length === 0 || steps[steps.length - 1][1] === "tabs") return void 0;
2679
2906
  let current = root;
2680
- for (let i = 0; i < steps.length - 1; i++) {
2907
+ for (let i = 0; i < steps.length; i++) {
2681
2908
  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;
2909
+ const list = step[1] === void 0 ? current : obj(current)?.[step[1]];
2910
+ const index = Number(step[2] ?? step[3]);
2911
+ if (!Array.isArray(list) || index >= list.length) return void 0;
2912
+ if (i === steps.length - 1) return {
2913
+ array: list,
2914
+ index,
2915
+ preserveIndex: step[1] === "details"
2916
+ };
2917
+ current = list[index];
2685
2918
  if (current === void 0) return void 0;
2686
2919
  }
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
2920
  }
2695
2921
  /** Deep-clone a JSON value for pruning; null when it cannot round-trip. */
2696
2922
  function cloneJsonValue(value) {
@@ -2716,20 +2942,31 @@ function partialRepairGenuiSpec(processed) {
2716
2942
  if (isRenderableProcess(processed)) return processed.spec;
2717
2943
  if (processed.spec === null) return null;
2718
2944
  const root = obj(processed.value);
2719
- if (root === void 0 || isComponentRoot(root)) return null;
2720
- if (processed.declaredNativeCount <= 1) return null;
2945
+ if (root === void 0) return null;
2721
2946
  const paths = /* @__PURE__ */ new Set();
2947
+ const overhangPaths = /* @__PURE__ */ new Set();
2722
2948
  for (const error of processed.errors) {
2723
2949
  if (error.startsWith("spec exceeds ")) continue;
2724
2950
  const nodePath = errorNodePath(error);
2725
- if (nodePath !== null) paths.add(nodePath);
2951
+ if (nodePath === null) continue;
2952
+ if (error === `${nodePath}.details must not contain more entries than rows`) overhangPaths.add(nodePath);
2953
+ else paths.add(nodePath);
2726
2954
  }
2727
- if (paths.size === 0) return null;
2728
- const pruned = cloneJsonValue(processed.value);
2955
+ if (paths.size === 0 && overhangPaths.size === 0) return null;
2956
+ const pruned = cloneJsonValue(isComponentRoot(root) ? wrapSingleComponentRoot(root) : root);
2729
2957
  if (pruned === null) return null;
2730
2958
  const slots = [...paths].map((path) => nodeSlotAt(pruned, path)).filter((slot) => slot !== void 0);
2959
+ const overhangSlots = [...overhangPaths].map((path) => nodeSlotAt(pruned, path)).filter((slot) => slot !== void 0);
2960
+ for (const { array, index } of overhangSlots) {
2961
+ const table = obj(array[index]);
2962
+ if (table?.type === "table" && Array.isArray(table.details)) table.details.length = Math.min(table.details.length, tableRowsForDetails(table).length);
2963
+ }
2731
2964
  const byArray = /* @__PURE__ */ new Map();
2732
- for (const { array, index } of slots) {
2965
+ for (const { array, index, preserveIndex } of slots) {
2966
+ if (preserveIndex) {
2967
+ array[index] = null;
2968
+ continue;
2969
+ }
2733
2970
  const indexes = byArray.get(array);
2734
2971
  if (indexes === void 0) byArray.set(array, [index]);
2735
2972
  else indexes.push(index);
@@ -2913,6 +3150,24 @@ function validateNode(value, depth, at, errors, walk) {
2913
3150
  if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`);
2914
3151
  if (v.types !== void 0 && !Array.isArray(v.types)) errors.push(`${at}.types must be an array of column cell types`);
2915
3152
  if (v.details !== void 0 && !Array.isArray(v.details)) errors.push(`${at}.details must be an array aligned with rows`);
3153
+ if (Array.isArray(v.details)) {
3154
+ const table = {
3155
+ columns: v.columns,
3156
+ rows: v.rows,
3157
+ types: v.types
3158
+ };
3159
+ const rowCount = tableRowsForDetails(table).length;
3160
+ if (v.details.length > rowCount) errors.push(`${at}.details must not contain more entries than rows`);
3161
+ for (let i = 0; i < Math.min(v.details.length, rowCount); i++) {
3162
+ const detail = v.details[i];
3163
+ if (detail === null || !isTableDetailReachable(table, i)) continue;
3164
+ if (!Array.isArray(detail)) {
3165
+ errors.push(`${at}.details[${i}] must be an array or null`);
3166
+ continue;
3167
+ }
3168
+ walk(detail, depth + 1, `${at}.details[${i}]`);
3169
+ }
3170
+ }
2916
3171
  validateTableRows(v.rows, `${at}.rows`, errors);
2917
3172
  break;
2918
3173
  case "chart":
@@ -3016,11 +3271,10 @@ function validateNode(value, depth, at, errors, walk) {
3016
3271
  * fail near that quote with "Expected ',' or ']'...".
3017
3272
  * 2. Trailing commas before `}` / `]` or at end of input.
3018
3273
  *
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.
3274
+ * A shared grammar-aware scan distinguishes object keys from values and
3275
+ * checks the continuation after a potential string terminator. Ambiguous
3276
+ * value quotes use bounded backtracking; trailing commas are dropped only
3277
+ * outside strings.
3024
3278
  *
3025
3279
  * Returns `{ text, repairs }` on success, or null when nothing needed fixing
3026
3280
  * or the body still does not parse (callers fall through to tier-2 / banner).
@@ -3030,61 +3284,286 @@ function repairFenceJson(raw) {
3030
3284
  JSON.parse(raw);
3031
3285
  return null;
3032
3286
  } catch {}
3287
+ return scanFenceJson(raw, false);
3288
+ }
3289
+ /** A fixed search budget prevents quote ambiguity from becoming exponential. */
3290
+ const MAX_QUOTE_ATTEMPTS = 32;
3291
+ const MAX_QUOTE_LOOKAHEAD = 4096;
3292
+ function isJsonSpace(ch) {
3293
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r";
3294
+ }
3295
+ /**
3296
+ * Quote closure is contextual: only keys may be followed by `:`, and a comma
3297
+ * must introduce the next member of the enclosing object/array. Walk closing
3298
+ * delimiters too, so a bracket in a quoted code example cannot end a value
3299
+ * when prose immediately follows it inside the enclosing JSON structure.
3300
+ * Lookahead has a fixed bound; inconclusive long continuations keep the
3301
+ * terminator interpretation and leave the final JSON.parse as the arbiter.
3302
+ */
3303
+ function quoteCanClose(raw, index, key, scopes, complete) {
3304
+ const limit = Math.min(raw.length, index + MAX_QUOTE_LOOKAHEAD);
3305
+ let cursor = index + 1;
3306
+ const skipSpace = () => {
3307
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++;
3308
+ };
3309
+ skipSpace();
3310
+ if (cursor >= limit) return true;
3311
+ if (key) return raw[cursor] === ":";
3312
+ let scopeIndex = scopes.length - 1;
3313
+ if (raw[cursor] === ":") return false;
3314
+ while (raw[cursor] === "}" || raw[cursor] === "]") {
3315
+ const scope = scopes[scopeIndex];
3316
+ if (scope === void 0) return false;
3317
+ if (raw[cursor] === scope.closer) scopeIndex--;
3318
+ else if (!complete) return false;
3319
+ cursor++;
3320
+ skipSpace();
3321
+ if (cursor >= limit) return true;
3322
+ if (scopeIndex < 0) return complete;
3323
+ }
3324
+ if (raw[cursor] !== ",") return false;
3325
+ const scope = scopes[scopeIndex];
3326
+ if (scope === void 0) return false;
3327
+ cursor++;
3328
+ skipSpace();
3329
+ if (cursor >= limit) return true;
3330
+ if (raw[cursor] === scope.closer) return true;
3331
+ if (scope.closer === "]") return /["[{tfn\-0-9]/.test(raw[cursor]);
3332
+ if (raw[cursor] !== "\"") return false;
3333
+ cursor++;
3334
+ while (cursor < limit) {
3335
+ if (raw[cursor] === "\\") {
3336
+ cursor += 2;
3337
+ continue;
3338
+ }
3339
+ if (raw[cursor] === "\"") {
3340
+ cursor++;
3341
+ skipSpace();
3342
+ return cursor >= limit || raw[cursor] === ":";
3343
+ }
3344
+ if (raw[cursor].charCodeAt(0) < 32) return false;
3345
+ cursor++;
3346
+ }
3347
+ return true;
3348
+ }
3349
+ /** Both tiers share the same string-aware scan and quote decisions. */
3350
+ function scanFenceCandidate(raw, complete, contentQuotes) {
3033
3351
  let out = "";
3352
+ const scopes = [];
3034
3353
  let inString = false;
3354
+ let key = false;
3355
+ let interiorQuote = false;
3035
3356
  let escaped = false;
3036
3357
  let repairs = 0;
3358
+ let rootEnd = 0;
3359
+ let rawRootEnd = 0;
3360
+ let invalidValue = false;
3361
+ const choices = [];
3362
+ const finishValue = () => {
3363
+ const scope = scopes[scopes.length - 1];
3364
+ if (scope !== void 0) scope.expecting = "comma";
3365
+ };
3037
3366
  for (let i = 0; i < raw.length; i++) {
3038
3367
  const ch = raw[i];
3039
3368
  if (escaped) {
3369
+ if (inString && ch === "\"") interiorQuote = true;
3040
3370
  out += ch;
3041
3371
  escaped = false;
3042
3372
  continue;
3043
3373
  }
3044
- if (inString && ch === "\\") {
3045
- out += ch;
3046
- escaped = true;
3047
- continue;
3048
- }
3049
- if (ch === "\"") {
3050
- if (!inString) {
3051
- inString = true;
3374
+ if (inString) {
3375
+ if (ch === "\\") {
3052
3376
  out += ch;
3377
+ escaped = true;
3053
3378
  continue;
3054
3379
  }
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 === "") {
3380
+ if (ch !== "\"") {
3381
+ out += ch;
3382
+ continue;
3383
+ }
3384
+ let next = i + 1;
3385
+ while (next < raw.length && isJsonSpace(raw[next])) next++;
3386
+ const ordinaryEnd = !key && (!interiorQuote && ",}]:".includes(raw[next] ?? "\0") || scopes[scopes.length - 1]?.closer === "]" && raw[next] === ",");
3387
+ if (!contentQuotes.has(i) && (ordinaryEnd || quoteCanClose(raw, i, key, scopes, complete))) {
3059
3388
  inString = false;
3060
3389
  out += ch;
3390
+ const scope = scopes[scopes.length - 1];
3391
+ if (key) {
3392
+ if (scope !== void 0) scope.expecting = "colon";
3393
+ } else {
3394
+ finishValue();
3395
+ if (scope !== void 0 && interiorQuote) {
3396
+ if (choices.length === MAX_QUOTE_ATTEMPTS) choices.shift();
3397
+ choices.push(i);
3398
+ }
3399
+ }
3061
3400
  } else {
3062
3401
  out += "\\\"";
3402
+ interiorQuote = true;
3063
3403
  repairs++;
3064
3404
  }
3065
3405
  continue;
3066
3406
  }
3407
+ if (ch === "\"") {
3408
+ key = scopes[scopes.length - 1]?.expecting === "key";
3409
+ interiorQuote = false;
3410
+ inString = true;
3411
+ out += ch;
3412
+ continue;
3413
+ }
3414
+ if (ch === "{" || ch === "[") {
3415
+ finishValue();
3416
+ scopes.push({
3417
+ closer: ch === "{" ? "}" : "]",
3418
+ expecting: ch === "{" ? "key" : "value"
3419
+ });
3420
+ out += ch;
3421
+ continue;
3422
+ }
3423
+ if (ch === "}" || ch === "]") {
3424
+ if (scopes[scopes.length - 1]?.closer === ch) {
3425
+ scopes.pop();
3426
+ out += ch;
3427
+ if (scopes.length === 0 && rootEnd === 0) {
3428
+ rootEnd = out.length;
3429
+ rawRootEnd = i + 1;
3430
+ }
3431
+ } else if (complete) repairs++;
3432
+ else out += ch;
3433
+ continue;
3434
+ }
3067
3435
  if (ch === ",") {
3068
3436
  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 === "") {
3437
+ while (j < raw.length && isJsonSpace(raw[j])) j++;
3438
+ if (j === raw.length || raw[j] === "}" || raw[j] === "]") {
3072
3439
  repairs++;
3073
3440
  continue;
3074
3441
  }
3442
+ const scope = scopes[scopes.length - 1];
3443
+ if (scope !== void 0) scope.expecting = scope.closer === "}" ? "key" : "value";
3444
+ } else if (ch === ":") {
3445
+ const scope = scopes[scopes.length - 1];
3446
+ if (scope !== void 0) scope.expecting = "value";
3447
+ } else if (!isJsonSpace(ch) && scopes[scopes.length - 1]?.expecting === "value") {
3448
+ let j = i + 1;
3449
+ while (j < raw.length && !isJsonSpace(raw[j]) && !"{}[],:\"".includes(raw[j])) j++;
3450
+ const token = raw.slice(i, j);
3451
+ if (!/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(token)) invalidValue = true;
3452
+ finishValue();
3453
+ out += token;
3454
+ i = j - 1;
3455
+ continue;
3075
3456
  }
3076
3457
  out += ch;
3077
3458
  }
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;
3459
+ const unfinishedString = inString;
3460
+ if (complete) {
3461
+ if (inString) {
3462
+ out += "\"";
3463
+ repairs++;
3464
+ }
3465
+ while (scopes.length > 0) {
3466
+ out += scopes.pop().closer;
3467
+ repairs++;
3468
+ }
3469
+ }
3470
+ return {
3471
+ text: out,
3472
+ repairs,
3473
+ rootEnd,
3474
+ rawRootEnd,
3475
+ choices,
3476
+ unfinishedString,
3477
+ invalidValue
3478
+ };
3479
+ }
3480
+ /** Find an authoritative, already-valid object/array prefix without repair. */
3481
+ function originalJsonPrefix(raw) {
3482
+ const scopes = [];
3483
+ let inString = false;
3484
+ let escaped = false;
3485
+ let started = false;
3486
+ for (let i = 0; i < raw.length; i++) {
3487
+ const ch = raw[i];
3488
+ if (escaped) {
3489
+ escaped = false;
3490
+ continue;
3491
+ }
3492
+ if (inString) {
3493
+ if (ch === "\\") escaped = true;
3494
+ else if (ch === "\"") inString = false;
3495
+ continue;
3496
+ }
3497
+ if (!started) {
3498
+ if (isJsonSpace(ch)) continue;
3499
+ if (ch !== "{" && ch !== "[") return null;
3500
+ started = true;
3501
+ }
3502
+ if (ch === "\"") {
3503
+ inString = true;
3504
+ continue;
3505
+ }
3506
+ if (ch === "{" || ch === "[") scopes.push(ch === "{" ? "}" : "]");
3507
+ else if (ch === "}" || ch === "]") {
3508
+ if (scopes.pop() !== ch) return null;
3509
+ if (scopes.length === 0) {
3510
+ const text = raw.slice(0, i + 1).trimEnd();
3511
+ try {
3512
+ JSON.parse(text);
3513
+ return text;
3514
+ } catch {
3515
+ return null;
3516
+ }
3517
+ }
3518
+ }
3519
+ }
3520
+ return null;
3521
+ }
3522
+ /**
3523
+ * Prefer real terminators; if the resulting whole body fails, try interpreting
3524
+ * a bounded number of ambiguous value quotes as content. Each attempt is an
3525
+ * iterative scan, not a recursive parser; no engine-specific error offset is
3526
+ * needed. Fixed attempts/lookahead give O(n) time and O(n) working storage.
3527
+ */
3528
+ function scanFenceJson(raw, complete) {
3529
+ const original = originalJsonPrefix(raw);
3530
+ if (original !== null) return complete ? {
3531
+ text: original,
3532
+ repairs: 1
3533
+ } : null;
3534
+ const pending = [[]];
3535
+ let prefix = null;
3536
+ for (let attempt = 0; attempt < MAX_QUOTE_ATTEMPTS && pending.length > 0; attempt++) {
3537
+ const forced = pending.pop();
3538
+ const scanned = scanFenceCandidate(raw, complete, new Set(forced));
3539
+ if (scanned.repairs > 0 && !(forced.length > 0 && scanned.unfinishedString)) try {
3540
+ JSON.parse(scanned.text);
3541
+ return {
3542
+ text: scanned.text,
3543
+ repairs: scanned.repairs
3544
+ };
3545
+ } catch {}
3546
+ if (complete && scanned.rootEnd > 0 && (prefix === null || scanned.rawRootEnd > prefix.rawEnd)) {
3547
+ const text = scanned.text.slice(0, scanned.rootEnd).trimEnd();
3548
+ try {
3549
+ JSON.parse(text);
3550
+ prefix = {
3551
+ text,
3552
+ repairs: scanned.repairs + 1,
3553
+ rawEnd: scanned.rawRootEnd
3554
+ };
3555
+ } catch {}
3556
+ }
3557
+ if (scanned.invalidValue) continue;
3558
+ const last = forced[forced.length - 1] ?? -1;
3559
+ for (const quote of scanned.choices) if (quote > last) pending.push([...forced, quote]);
3560
+ if (pending.length > MAX_QUOTE_ATTEMPTS) pending.splice(0, pending.length - MAX_QUOTE_ATTEMPTS);
3087
3561
  }
3562
+ if (prefix !== null) return {
3563
+ text: prefix.text,
3564
+ repairs: prefix.repairs
3565
+ };
3566
+ return null;
3088
3567
  }
3089
3568
  /**
3090
3569
  * Rewrite the "Tetris table" shape into legal JSON: the model closed the
@@ -3215,8 +3694,8 @@ function rewriteTetrisTableColumns(raw) {
3215
3694
  * client uses the host-provided fence source; the validate tool is by
3216
3695
  * definition pre-emission), so a streaming half can never flash premature UI.
3217
3696
  *
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
3697
+ * One shared scan implementation folds the tier-1 fixes (quote escaping +
3698
+ * trailing-comma drops) into structural completion, so bodies with BOTH defects
3220
3699
  * (a trailing comma AND a missing closer) heal in one shot — the old
3221
3700
  * two-phase chain lost tier-1's partial work when its whole-body parse
3222
3701
  * failed, and re-scanning the raw text could not compose the repairs.
@@ -3240,91 +3719,7 @@ function completeFenceJson(raw) {
3240
3719
  repairs: scanned.repairs + tetris.repairs
3241
3720
  };
3242
3721
  }
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
- }
3722
+ return scanFenceJson(raw, true);
3328
3723
  }
3329
3724
  //#endregion
3330
3725
  //#region src/plugin/genui-diagnostic.ts
@@ -4288,25 +4683,83 @@ function createFeedbackMessage(text, sessionFormatVersion) {
4288
4683
  return Object.freeze(message);
4289
4684
  }
4290
4685
  /**
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.
4686
+ * Decide whether this turn boundary should steer a correction — the pure core of
4687
+ * the loop, so every bound (per-turn cap, per-fence ledger, cancellation) is
4688
+ * testable without a host.
4689
+ *
4690
+ * The decision is driven by FORMAL events only: fences in the reply body, a
4691
+ * `validate_dsh_ui` call, a delivered body text or a successful `render_ui`
4692
+ * result. The reasoning block is
4693
+ * never read here — a draft inside the thinking block is not proof that the model
4694
+ * chose to deliver it, so it must not change any decision.
4294
4695
  *
4295
- * @param input - reply text, turn identity, and the session's accounting.
4696
+ * @param input - reply text, turn identity, formal signals, and the accounting.
4296
4697
  * @returns the correction to send, or null when the loop must stay silent.
4297
4698
  */
4298
4699
  function planFenceFeedback(input) {
4299
4700
  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 {
4701
+ const used = input.correctionsTurn === input.turn ? input.correctionsThisTurn ?? 0 : 0;
4702
+ if (used >= 2) return null;
4703
+ const failures = fenceFailures(input.text).filter((failure) => !input.correctedSpec.has(failure.fingerprint));
4704
+ if (failures.length > 0) return {
4305
4705
  text: fenceCorrectionText(failures),
4306
4706
  fingerprints: failures.map((failure) => failure.fingerprint),
4307
- turn: input.turn
4707
+ turn: input.turn,
4708
+ kind: "render"
4709
+ };
4710
+ if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null;
4711
+ if (input.deliveryRemindedTurns?.has(input.turn) === true) return null;
4712
+ return {
4713
+ text: missingBodyCorrectionText(input.turn, used + 1),
4714
+ fingerprints: [],
4715
+ turn: input.turn,
4716
+ kind: "delivery"
4308
4717
  };
4309
4718
  }
4719
+ /**
4720
+ * Correction for a validated GenUI turn that reached the boundary without any
4721
+ * formal delivery.
4722
+ *
4723
+ * @param turn - turn that reached the boundary.
4724
+ * @param attempt - correction number within the shared turn budget.
4725
+ * @returns the message text to steer into the running turn.
4726
+ */
4727
+ function missingBodyCorrectionText(turn, attempt = 1) {
4728
+ 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 围栏,或明确调用 render_ui。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`;
4729
+ }
4730
+ /**
4731
+ * 将 GenUI 回合中仅含 reasoning 的完整响应转换为宿主已有的可重试空响应错误。
4732
+ *
4733
+ * @param options - LLM stream waterfall 拦截的请求。
4734
+ * @param source - 本次请求的 provider stream。
4735
+ * @returns 原始 stream;仅符合条件的终止 stop 会被改写。
4736
+ */
4737
+ async function* retryReasoningOnlyGenuiStream(options, source) {
4738
+ const { EMPTY_RESPONSE_CODE, isAgentLoopRequest } = await import("@deepseek-ai/dsh-llm");
4739
+ if (!isAgentLoopRequest(options)) {
4740
+ yield* source;
4741
+ return;
4742
+ }
4743
+ let hasReasoningBlock = false;
4744
+ let hasOtherBlock = false;
4745
+ for await (const chunk of source) {
4746
+ if (chunk.type === "block-end") {
4747
+ if (chunk.block.type === "reasoning") hasReasoningBlock = true;
4748
+ else hasOtherBlock = true;
4749
+ }
4750
+ if (chunk.type === "finish" && chunk.reason.kind === "stop" && hasReasoningBlock && !hasOtherBlock) yield {
4751
+ type: "finish",
4752
+ reason: {
4753
+ kind: "error",
4754
+ failure: {
4755
+ message: "GenUI turn completed with reasoning only and no deliverable response",
4756
+ code: EMPTY_RESPONSE_CODE
4757
+ }
4758
+ }
4759
+ };
4760
+ else yield chunk;
4761
+ }
4762
+ }
4310
4763
  /** Text of one assistant message's text blocks, in order. */
4311
4764
  function textOfContent(content) {
4312
4765
  if (!Array.isArray(content)) return "";
@@ -4316,9 +4769,59 @@ function textOfContent(content) {
4316
4769
  return record.type === "text" && typeof record.text === "string" ? record.text : "";
4317
4770
  }).filter((part) => part !== "").join("\n");
4318
4771
  }
4319
- /** Fingerprints this loop already recorded inside a steered correction. */
4320
- function markersIn(text) {
4321
- const out = [];
4772
+ /** 读取 render_ui result protocol 中明确返回的 status。 */
4773
+ function renderResultStatus(content) {
4774
+ const lines = textOfContent(content).split(/\r?\n/u);
4775
+ if (lines[0]?.trim() !== "[genui-render]") return void 0;
4776
+ for (const line of lines.slice(1)) {
4777
+ if (line === "status=rendered") return "rendered";
4778
+ if (line === "status=invalid") return "invalid";
4779
+ }
4780
+ }
4781
+ /** 从 legacy wrapper 与 Session format v4 中提取 tool result 数据。 */
4782
+ function observedToolResult(message) {
4783
+ if (typeof message !== "object" || message === null) return null;
4784
+ const record = message;
4785
+ if (typeof record.toolCallId === "string") return {
4786
+ callId: record.toolCallId,
4787
+ isError: record.isError === true,
4788
+ content: record.content
4789
+ };
4790
+ if (!Array.isArray(record.content)) return null;
4791
+ const block = record.content.find((part) => typeof part === "object" && part !== null && part.type === "tool-result");
4792
+ if (typeof block?.toolCallId !== "string") return null;
4793
+ return {
4794
+ callId: block.toolCallId,
4795
+ isError: block.isError === true,
4796
+ content: block.content
4797
+ };
4798
+ }
4799
+ /** Tool whose SUCCESSFUL result is a formal delivery. */
4800
+ const DELIVERY_TOOL = "render_ui";
4801
+ /**
4802
+ * Whether one assistant message delivered a non-empty body text.
4803
+ *
4804
+ * Deliberately narrow: a non-empty text block. A `render_ui` call is decided
4805
+ * by its `tool/result`, not by the call appearing in a message — a call that
4806
+ * failed (or whose result has not arrived) is not a delivery. Other tools
4807
+ * (validate_dsh_ui, bash, …) may succeed without producing any answer, so
4808
+ * they are NOT counted as delivery.
4809
+ *
4810
+ * @param content - assistant content blocks.
4811
+ * @returns true when this message delivered a body text.
4812
+ */
4813
+ function deliveredBodyText(content) {
4814
+ if (!Array.isArray(content)) return false;
4815
+ return content.some((block) => {
4816
+ if (typeof block !== "object" || block === null) return false;
4817
+ const record = block;
4818
+ return record.type === "text" && typeof record.text === "string" && record.text.trim() !== "";
4819
+ });
4820
+ }
4821
+ /** 分类当前与 legacy correction message 中的 marker。 */
4822
+ function feedbackMarkersIn(text) {
4823
+ const renderFingerprints = [];
4824
+ const deliveryTurns = [];
4322
4825
  let cursor = 0;
4323
4826
  while (cursor < text.length) {
4324
4827
  const current = text.indexOf(MARKER_PREFIX, cursor);
@@ -4329,31 +4832,53 @@ function markersIn(text) {
4329
4832
  const index = useLegacy ? legacy : current;
4330
4833
  const end = text.indexOf("]", index + prefix.length);
4331
4834
  if (end < 0) break;
4332
- out.push(text.slice(index + prefix.length, end));
4835
+ const marker = text.slice(index + prefix.length, end);
4836
+ const turn = /^turn-(\d+)$/u.exec(marker);
4837
+ if (turn !== null) deliveryTurns.push(Number(turn[1]));
4838
+ else renderFingerprints.push(marker);
4333
4839
  cursor = end + 1;
4334
4840
  }
4335
- return out;
4841
+ return {
4842
+ renderFingerprints,
4843
+ deliveryTurns
4844
+ };
4845
+ }
4846
+ /** 恢复一条持久化 correction 消耗的 turn budget。 */
4847
+ function accountReplayedCorrection(state, turn) {
4848
+ if (state.correctionsTurn !== turn) {
4849
+ state.correctionsTurn = turn;
4850
+ state.correctionsThisTurn = 1;
4851
+ return;
4852
+ }
4853
+ state.correctionsThisTurn = Math.min(2, state.correctionsThisTurn + 1);
4336
4854
  }
4337
4855
  /** Identify this plugin's source across current and migrated session shapes. */
4338
4856
  function isFeedbackSource(source) {
4339
4857
  return source?.kind === FEEDBACK_SOURCE_KIND || source?.kind === "plugin" && source.plugin === "@changfenhuang/dsh-genui";
4340
4858
  }
4341
4859
  /**
4342
- * 根据插件配置启用围栏反馈流程。
4860
+ * 注册 GenUI 回合跟踪、宿主重试判定和可选的围栏修正流程。
4343
4861
  *
4344
- * @param ctx - the host context.
4345
- * @param enabled - the plugin config flag; the loop is inert when false.
4862
+ * @param ctx - 宿主 Context。
4863
+ * @param enabled - 是否启用同回合围栏修正。
4346
4864
  */
4347
4865
  function installFenceFeedback(ctx, enabled) {
4348
- if (!enabled) return;
4349
4866
  const sessions = /* @__PURE__ */ new Map();
4350
4867
  const stateOf = (sessionId) => {
4351
4868
  let state = sessions.get(sessionId);
4352
4869
  if (state === void 0) {
4353
4870
  state = {
4354
4871
  text: "",
4355
- corrected: /* @__PURE__ */ new Set(),
4356
- lastCorrectedTurn: void 0
4872
+ validatedThisTurn: false,
4873
+ isSubagent: false,
4874
+ deliveredThisTurn: false,
4875
+ pendingRenders: /* @__PURE__ */ new Set(),
4876
+ correctedSpec: /* @__PURE__ */ new Set(),
4877
+ deliveryRemindedTurns: /* @__PURE__ */ new Set(),
4878
+ correctionsThisTurn: 0,
4879
+ correctionsTurn: void 0,
4880
+ currentTurn: void 0,
4881
+ accountedCorrectionMessageIds: /* @__PURE__ */ new Set()
4357
4882
  };
4358
4883
  sessions.set(sessionId, state);
4359
4884
  }
@@ -4362,46 +4887,122 @@ function installFenceFeedback(ctx, enabled) {
4362
4887
  ctx.on("session/disposed", (session) => {
4363
4888
  sessions.delete(String(session.id));
4364
4889
  });
4365
- ctx.on("session/event", (session, event) => {
4890
+ const resetTurnState = (state, turn) => {
4891
+ state.text = "";
4892
+ state.validatedThisTurn = false;
4893
+ state.deliveredThisTurn = false;
4894
+ state.pendingRenders.clear();
4895
+ state.currentTurn = turn;
4896
+ };
4897
+ const observeEvent = (session, event) => {
4366
4898
  const sessionId = String(session.id);
4899
+ if (event.type === "turn/start") {
4900
+ const state = stateOf(sessionId);
4901
+ state.isSubagent = session.header.parentSession !== void 0;
4902
+ state.correctedSpec.clear();
4903
+ resetTurnState(state, event.data.turn);
4904
+ return;
4905
+ }
4367
4906
  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;
4907
+ const content = event.data.message?.content;
4908
+ const text = textOfContent(content);
4909
+ const state = stateOf(sessionId);
4910
+ state.isSubagent = session.header.parentSession !== void 0;
4911
+ state.text = extractDshUiFences(text).length > 0 ? text : "";
4912
+ state.deliveredThisTurn = state.deliveredThisTurn || deliveredBodyText(content);
4913
+ return;
4914
+ }
4915
+ if (event.type === "tool/call") {
4916
+ const data = event.data;
4917
+ const state = stateOf(sessionId);
4918
+ state.isSubagent = session.header.parentSession !== void 0;
4919
+ if (data.name === "validate_dsh_ui") state.validatedThisTurn = true;
4920
+ if (data.name === DELIVERY_TOOL && typeof data.callId === "string") state.pendingRenders.add(data.callId);
4921
+ return;
4922
+ }
4923
+ if (event.type === "tool/result") {
4924
+ const data = event.data;
4925
+ const result = observedToolResult(data.message);
4926
+ if (result === null) return;
4927
+ const state = stateOf(sessionId);
4928
+ state.isSubagent = session.header.parentSession !== void 0;
4929
+ if (!state.pendingRenders.delete(result.callId)) return;
4930
+ if (data.error === void 0 && result.isError !== true && renderResultStatus(result.content) === "rendered") state.deliveredThisTurn = true;
4375
4931
  return;
4376
4932
  }
4377
4933
  if (event.type !== "user/message") return;
4378
4934
  const data = event.data;
4379
4935
  if (isFeedbackSource(data.source)) {
4380
- const fingerprints = markersIn(textOfContent(data.content));
4381
- if (fingerprints.length === 0) return;
4936
+ const markers = feedbackMarkersIn(textOfContent(data.content));
4937
+ if (markers.renderFingerprints.length === 0 && markers.deliveryTurns.length === 0) return;
4382
4938
  const state = stateOf(sessionId);
4383
- for (const fingerprint of fingerprints) state.corrected.add(fingerprint);
4939
+ state.isSubagent = session.header.parentSession !== void 0;
4940
+ for (const fingerprint of markers.renderFingerprints) state.correctedSpec.add(fingerprint);
4941
+ for (const turn of markers.deliveryTurns) state.deliveryRemindedTurns.add(turn);
4942
+ if (typeof data.id === "string" && !state.accountedCorrectionMessageIds.has(data.id)) {
4943
+ state.accountedCorrectionMessageIds.add(data.id);
4944
+ const turn = markers.deliveryTurns[0] ?? state.currentTurn;
4945
+ if (turn !== void 0) accountReplayedCorrection(state, turn);
4946
+ }
4384
4947
  return;
4385
4948
  }
4949
+ if (data.source?.kind !== "user") return;
4386
4950
  const state = sessions.get(sessionId);
4387
- if (state !== void 0) state.text = "";
4951
+ if (state !== void 0) {
4952
+ state.isSubagent = session.header.parentSession !== void 0;
4953
+ resetTurnState(state, state.currentTurn);
4954
+ }
4955
+ };
4956
+ const restoreHistory = (session) => {
4957
+ if (sessions.has(String(session.id))) return;
4958
+ for (const event of session.snapshotEvents()) observeEvent(session, event);
4959
+ };
4960
+ ctx.on("session/event", (session, event) => {
4961
+ restoreHistory(session);
4962
+ observeEvent(session, event);
4388
4963
  });
4964
+ ctx.on("session/created", restoreHistory);
4965
+ const restoreSessions = (sessionCtx) => {
4966
+ const store = sessionCtx.reflect.get("sessions");
4967
+ if (store === void 0) return;
4968
+ for (const session of store.list()) restoreHistory(session);
4969
+ };
4970
+ restoreSessions(ctx);
4971
+ ctx.inject(["sessions"], restoreSessions);
4972
+ ctx.on("llm/stream", (options, next) => {
4973
+ if (options.sessionId === void 0 || options.purpose !== void 0) return next();
4974
+ const state = sessions.get(String(options.sessionId));
4975
+ if (state?.validatedThisTurn !== true || state.isSubagent) return next();
4976
+ return retryReasoningOnlyGenuiStream(options, next());
4977
+ }, { global: true });
4389
4978
  ctx.on("agent/turn-stopping", ({ agent, turn, signal }) => {
4979
+ if (!enabled) return;
4390
4980
  if (agent.session.header.parentSession !== void 0) return;
4391
4981
  const state = sessions.get(String(agent.session.id));
4392
4982
  if (state === void 0) return;
4983
+ state.currentTurn = turn;
4984
+ if (state.pendingRenders.size > 0) return;
4985
+ const usedThisTurn = state.correctionsTurn === turn ? state.correctionsThisTurn : 0;
4393
4986
  const plan = planFenceFeedback({
4394
4987
  text: state.text,
4395
4988
  turn,
4396
- lastCorrectedTurn: state.lastCorrectedTurn,
4397
- corrected: state.corrected,
4398
- aborted: signal.aborted
4989
+ correctedSpec: state.correctedSpec,
4990
+ deliveryRemindedTurns: state.deliveryRemindedTurns,
4991
+ validatedThisTurn: state.validatedThisTurn,
4992
+ deliveredThisTurn: state.deliveredThisTurn,
4993
+ aborted: signal.aborted,
4994
+ correctionsThisTurn: usedThisTurn,
4995
+ correctionsTurn: state.correctionsTurn
4399
4996
  });
4400
4997
  if (plan === null) return;
4401
- for (const fingerprint of plan.fingerprints) state.corrected.add(fingerprint);
4402
- state.lastCorrectedTurn = plan.turn;
4998
+ for (const fingerprint of plan.fingerprints) state.correctedSpec.add(fingerprint);
4999
+ if (plan.kind === "delivery") state.deliveryRemindedTurns.add(plan.turn);
5000
+ state.correctionsTurn = plan.turn;
5001
+ state.correctionsThisTurn = usedThisTurn + 1;
4403
5002
  try {
4404
- agent.steer(createFeedbackMessage(plan.text, agent.session.header.version));
5003
+ const message = createFeedbackMessage(plan.text, agent.session.header.version);
5004
+ state.accountedCorrectionMessageIds.add(message.id);
5005
+ agent.steer(message);
4405
5006
  } catch (error) {
4406
5007
  ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`);
4407
5008
  }
@@ -4499,12 +5100,13 @@ Rules:
4499
5100
  - JSON 严格:坏组件被丢弃,坏围栏变代码块;≥3 节点或含 table 时调 validate_dsh_ui,按诊断修改并重验;小围栏字段存疑也先验证。
4500
5101
  - warning=block_markdown:按 replacement 改写并重验。
4501
5102
  - 规模: ≤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: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;重渲染相同内容保留,新内容重置。
5103
+ - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
5104
+ - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
4504
5105
  - 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
4505
5106
  - 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 字内确认,不解释、不用普通围栏。`;
5107
+ - Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。
5108
+ - 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
5109
+ - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`;
4508
5110
  /**
4509
5111
  * Register the GenUI output-language section and the render_ui tool.
4510
5112
  * @param ctx - cordis context.