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