@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/CHANGELOG.md +25 -0
- package/README.md +13 -4
- package/README.zh-CN.md +13 -4
- package/SKILL.md +16 -4
- package/lib/assets/standalone-runtime.js +7 -6
- package/lib/client.js +15 -17
- package/lib/index.js +956 -354
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/spec.d.ts +8 -11
- package/lib/types/client/submission-registry.d.ts +51 -0
- package/lib/types/client/table-details.d.ts +20 -0
- package/lib/types/client/walk-spec.d.ts +3 -0
- package/lib/types/plugin/fence-feedback.d.ts +50 -11
- package/lib/types/plugin/index.d.ts +3 -3
- package/lib/types/shared/fence-repair.d.ts +18 -7
- package/package.json +20 -19
- package/src/client/GenuiBlock.module.css +65 -1
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/blocks/charts.tsx +41 -8
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +1 -1
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +94 -21
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +93 -151
- package/src/client/inline.ts +11 -4
- package/src/client/spec.ts +8 -11
- package/src/client/submission-registry.ts +150 -0
- package/src/client/table-details.ts +151 -0
- package/src/client/walk-spec.ts +43 -0
- package/src/plugin/fence-feedback.ts +372 -53
- package/src/plugin/index.ts +7 -6
- package/src/shared/fence-repair.ts +309 -128
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
|
|
1129
|
+
if (holder === void 0) return child;
|
|
939
1130
|
const normalizedHolder = { ...holder };
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
|
|
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/
|
|
1125
|
-
/**
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
2907
|
+
for (let i = 0; i < steps.length; i++) {
|
|
2681
2908
|
const step = steps[i];
|
|
2682
|
-
const
|
|
2683
|
-
const
|
|
2684
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
3020
|
-
*
|
|
3021
|
-
*
|
|
3022
|
-
*
|
|
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
|
|
3045
|
-
|
|
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
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
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]
|
|
3070
|
-
|
|
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
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
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
|
-
*
|
|
3219
|
-
*
|
|
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
|
-
|
|
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
|
|
4292
|
-
*
|
|
4293
|
-
*
|
|
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
|
|
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
|
-
|
|
4301
|
-
if (
|
|
4302
|
-
const failures = fenceFailures(input.text).filter((failure) => !input.
|
|
4303
|
-
if (failures.length
|
|
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
|
-
/**
|
|
4320
|
-
function
|
|
4321
|
-
const
|
|
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
|
-
|
|
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
|
|
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 -
|
|
4345
|
-
* @param enabled -
|
|
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
|
-
|
|
4356
|
-
|
|
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
|
-
|
|
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
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
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
|
|
4381
|
-
if (
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
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.
|
|
4402
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4507
|
-
-
|
|
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.
|