@changfenhuang/dsh-genui 0.11.3 → 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 +16 -0
- package/README.md +10 -3
- package/README.zh-CN.md +10 -3
- 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 +1 -1
- 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/src/client/guard.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
|
|
|
31
31
|
import { diagnoseUnknownGenuiFields } from './genui-runtime/diagnostics.ts'
|
|
32
32
|
import type { GenuiDiagnostic } from './genui-runtime/diagnostics.ts'
|
|
33
33
|
import { GENUI_LIMITS } from './genui-runtime/limits.ts'
|
|
34
|
+
import { analyzeSubmissionRegistry } from './submission-registry.ts'
|
|
35
|
+
import { isTableDetailReachable, prepareTableRows, tableRowsForDetails } from './table-details.ts'
|
|
34
36
|
import { color, enu, int, num, obj, opt, safeHref, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
|
|
35
37
|
|
|
36
38
|
/** Result of `validateGenuiSpec`. */
|
|
@@ -418,41 +420,9 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
|
|
|
418
420
|
return { type: 'list', items, ...opt('filter', str(v.filter, 64)) }
|
|
419
421
|
}
|
|
420
422
|
case 'table': {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// ({title,key}) become header strings, and object-array rows (or a
|
|
425
|
-
// `data` alias) flatten to 2D rows keyed by the column keys — without
|
|
426
|
-
// this the whole node is dropped for "missing 2D rows" and the user
|
|
427
|
-
// sees nothing (issue #42).
|
|
428
|
-
if (Array.isArray(rawCols) && rawCols.length > 0 && typeof rawCols[0] === 'object' && rawCols[0] !== null) {
|
|
429
|
-
rawCols = rawCols.map(c => columnHeaderText(c))
|
|
430
|
-
}
|
|
431
|
-
if (Array.isArray(rawRows) && rawRows.length > 0 && typeof rawRows[0] === 'object' && rawRows[0] !== null && !Array.isArray(rawRows[0])) {
|
|
432
|
-
const keys = Array.isArray(v.columns) && v.columns.length > 0 && typeof v.columns[0] === 'object' && v.columns[0] !== null
|
|
433
|
-
? v.columns.map(c => columnKeyOf(c)).filter((k): k is string => k !== undefined)
|
|
434
|
-
: Object.keys(rawRows[0] as Record<string, unknown>)
|
|
435
|
-
rawRows = rawRows.map(row => keys.map(k => cellText((row as Record<string, unknown>)[k])))
|
|
436
|
-
}
|
|
437
|
-
// Headerless rows: a 2D `rows`/`data` array with no `columns` states its
|
|
438
|
-
// own column names in its leading row, so derive them instead of
|
|
439
|
-
// dropping the node (and with it, the whole fence). A derivation the
|
|
440
|
-
// cell repair cannot reproduce (malformed cells) falls through to the
|
|
441
|
-
// existing drop-and-report behaviour.
|
|
442
|
-
let derived: { columns: string[]; rows: Array<Array<string | number>> } | null = null
|
|
443
|
-
if ((!Array.isArray(rawCols) || rawCols.length === 0)
|
|
444
|
-
&& Array.isArray(rawRows) && rawRows.length > 0 && Array.isArray(rawRows[0])) {
|
|
445
|
-
const grid = repairRows(rawRows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)
|
|
446
|
-
const candidate = grid === undefined || grid.length === 0 ? null : deriveTableColumns(grid)
|
|
447
|
-
if (candidate !== null
|
|
448
|
-
&& repairRows(candidate.rows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)?.length === candidate.rows.length) {
|
|
449
|
-
derived = candidate
|
|
450
|
-
rawCols = candidate.columns
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
const columns = repairStrings(rawCols, GENUI_LIMITS.maxTableCols, 128)
|
|
454
|
-
const rows = repairRows(derived === null ? rawRows : derived.rows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)
|
|
455
|
-
if (columns === undefined || rows === undefined) return null
|
|
423
|
+
const prepared = prepareTableRows(v)
|
|
424
|
+
if (prepared === null) return null
|
|
425
|
+
const { columns, rows } = prepared
|
|
456
426
|
// Optional per-column cell types; unknown entries degrade to 'text'.
|
|
457
427
|
const rawTypes = Array.isArray(v.types) ? v.types : undefined
|
|
458
428
|
const types = rawTypes === undefined
|
|
@@ -470,6 +440,7 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
|
|
|
470
440
|
const details = rawDetails === undefined
|
|
471
441
|
? undefined
|
|
472
442
|
: rows.map((_row, i) => {
|
|
443
|
+
if (!isTableDetailReachable({ columns, rows, types }, i)) return null
|
|
473
444
|
const entry = repairItems(rawDetails[i], ctx, depth + 1)
|
|
474
445
|
return entry.length === 0 ? null : entry
|
|
475
446
|
})
|
|
@@ -569,10 +540,7 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
|
|
|
569
540
|
}
|
|
570
541
|
case 'submit': {
|
|
571
542
|
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
572
|
-
// action
|
|
573
|
-
// needs no round trip, so a submit without an action is valid. It only
|
|
574
|
-
// becomes semantically required when no local answers exist — the
|
|
575
|
-
// renderer disables the button then (honest affordance).
|
|
543
|
+
// 本地判卷可以不设置 action;不满足判卷条件时,渲染器会禁用无 action 的按钮。
|
|
576
544
|
const action = str(v.action, 200)
|
|
577
545
|
if (label === undefined) return null
|
|
578
546
|
return {
|
|
@@ -760,11 +728,6 @@ function repairListItems(
|
|
|
760
728
|
continue
|
|
761
729
|
}
|
|
762
730
|
const o = obj(item)
|
|
763
|
-
const title = o === undefined ? undefined : str(o.title, GENUI_LIMITS.maxString)
|
|
764
|
-
if (title !== undefined) {
|
|
765
|
-
out.push({ title, ...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString) ?? str(o.description, GENUI_LIMITS.maxString)) })
|
|
766
|
-
continue
|
|
767
|
-
}
|
|
768
731
|
if (o !== undefined && typeof o.type === 'string') {
|
|
769
732
|
// Typed children are GenuiNodes: charge them against the shared node
|
|
770
733
|
// budget (module header promise — exhausted budget elides remaining
|
|
@@ -774,58 +737,16 @@ function repairListItems(
|
|
|
774
737
|
ctx.remaining -= 1
|
|
775
738
|
const node = repairNode(o, ctx, depth)
|
|
776
739
|
if (node !== null) out.push(node)
|
|
740
|
+
continue
|
|
741
|
+
}
|
|
742
|
+
const title = o === undefined ? undefined : str(o.title, GENUI_LIMITS.maxString)
|
|
743
|
+
if (title !== undefined) {
|
|
744
|
+
out.push({ title, ...opt('desc', str(o?.desc, GENUI_LIMITS.maxString)) })
|
|
777
745
|
}
|
|
778
746
|
}
|
|
779
747
|
return out
|
|
780
748
|
}
|
|
781
749
|
|
|
782
|
-
function repairRows(v: unknown, rowCap: number, colCap: number): Array<Array<string | number>> | undefined {
|
|
783
|
-
if (!Array.isArray(v)) return undefined
|
|
784
|
-
const out: Array<Array<string | number>> = []
|
|
785
|
-
for (const row of v) {
|
|
786
|
-
if (out.length >= rowCap) break
|
|
787
|
-
if (!Array.isArray(row)) continue
|
|
788
|
-
const cells: Array<string | number> = []
|
|
789
|
-
for (const cell of row) {
|
|
790
|
-
if (cells.length >= colCap) break
|
|
791
|
-
if (typeof cell === 'string') cells.push(cell.slice(0, 256))
|
|
792
|
-
else if (typeof cell === 'number' && Number.isFinite(cell)) cells.push(cell)
|
|
793
|
-
}
|
|
794
|
-
if (cells.length > 0) out.push(cells)
|
|
795
|
-
}
|
|
796
|
-
return out
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/** Left-aligned, undecorated columns for a table whose rows came without one. */
|
|
800
|
-
function derivedColumnNames(count: number): string[] {
|
|
801
|
-
return Array.from({ length: count }, (_unused, index) => `列${index + 1}`)
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/**
|
|
805
|
-
* Derive `columns` for a table that shipped only rows, without inventing
|
|
806
|
-
* content: the leading cell array is adopted as the header row and removed
|
|
807
|
-
* from the body — the shape both JSON table dumps and DataFrame-shaped
|
|
808
|
-
* exports are meant to be read as. Returns null when no unambiguous
|
|
809
|
-
* derivation exists (ragged rows) so the caller keeps its existing
|
|
810
|
-
* drop-and-report behaviour instead of rendering a fabricated header.
|
|
811
|
-
*/
|
|
812
|
-
function deriveTableColumns(rows: Array<Array<string | number>>): { columns: string[]; rows: Array<Array<string | number>> } | null {
|
|
813
|
-
const header = rows[0]
|
|
814
|
-
if (header === undefined || header.length === 0) return null
|
|
815
|
-
const body = rows.slice(1)
|
|
816
|
-
if (body.length === 0) {
|
|
817
|
-
// Header-only capture (a model dumping just its result header): render the
|
|
818
|
-
// stated columns with an empty body rather than fabricating a header row.
|
|
819
|
-
const columns = header.map(cell => String(cell).trim())
|
|
820
|
-
return columns.every(column => column !== '') ? { columns, rows: [] } : null
|
|
821
|
-
}
|
|
822
|
-
if (body.every(row => row.length === header.length)) {
|
|
823
|
-
return { columns: header.map(cell => String(cell).trim()), rows: body }
|
|
824
|
-
}
|
|
825
|
-
// Ragged body: nothing states the column names, so the leading row is data.
|
|
826
|
-
return { columns: derivedColumnNames(header.length), rows }
|
|
827
|
-
}
|
|
828
|
-
|
|
829
750
|
function repairChartData(v: unknown, cap: number): Array<{ label: string; value: number; color?: string }> | undefined {
|
|
830
751
|
if (!Array.isArray(v)) return undefined
|
|
831
752
|
const out: Array<{ label: string; value: number; color?: string }> = []
|
|
@@ -872,38 +793,6 @@ function repairTabs(v: unknown, ctx: RepairCtx, depth: number): Array<{ label: s
|
|
|
872
793
|
return out
|
|
873
794
|
}
|
|
874
795
|
|
|
875
|
-
/** Header text for an object-shaped table column ({title,key} antd style). */
|
|
876
|
-
function columnHeaderText(c: unknown): string {
|
|
877
|
-
const o = obj(c)
|
|
878
|
-
if (o === undefined) return String(c)
|
|
879
|
-
for (const k of ['title', 'label', 'key', 'dataIndex'] as const) {
|
|
880
|
-
const s = o[k]
|
|
881
|
-
if (typeof s === 'string' && s !== '') return s
|
|
882
|
-
}
|
|
883
|
-
return JSON.stringify(c)
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/** Row key for an object-shaped column, mirroring columnHeaderText's order. */
|
|
887
|
-
function columnKeyOf(c: unknown): string | undefined {
|
|
888
|
-
const o = obj(c)
|
|
889
|
-
if (o === undefined) return undefined
|
|
890
|
-
for (const k of ['key', 'dataIndex', 'title', 'label'] as const) {
|
|
891
|
-
const s = o[k]
|
|
892
|
-
if (typeof s === 'string' && s !== '') return s
|
|
893
|
-
}
|
|
894
|
-
return undefined
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
/** Cell text for object-array rows: strings/finite numbers pass through,
|
|
898
|
-
* everything else stringifies so the column alignment is preserved
|
|
899
|
-
* (repairRows would drop null/undefined cells and shift the row). */
|
|
900
|
-
function cellText(v: unknown): string | number {
|
|
901
|
-
if (typeof v === 'string') return v
|
|
902
|
-
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
903
|
-
if (v === null || v === undefined) return ''
|
|
904
|
-
return JSON.stringify(v)
|
|
905
|
-
}
|
|
906
|
-
|
|
907
796
|
function repairPlotSeries(v: unknown, cap: number): GenuiPlot['series'] | undefined {
|
|
908
797
|
if (!Array.isArray(v)) return undefined
|
|
909
798
|
const out: GenuiPlot['series'] = []
|
|
@@ -1376,6 +1265,13 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
|
|
|
1376
1265
|
const lo = obj(li)
|
|
1377
1266
|
if (lo !== undefined && typeof lo.type === 'string') walk([lo])
|
|
1378
1267
|
}
|
|
1268
|
+
} else if (v.type === 'table' && Array.isArray(v.details)) {
|
|
1269
|
+
const rowCount = tableRowsForDetails(v).length
|
|
1270
|
+
for (let rowIndex = 0; rowIndex < Math.min(v.details.length, rowCount); rowIndex++) {
|
|
1271
|
+
if (count >= cap) return
|
|
1272
|
+
const detail = v.details[rowIndex]
|
|
1273
|
+
if (Array.isArray(detail) && isTableDetailReachable(v, rowIndex)) walk(detail)
|
|
1274
|
+
}
|
|
1379
1275
|
}
|
|
1380
1276
|
}
|
|
1381
1277
|
}
|
|
@@ -1432,6 +1328,11 @@ function visitDeclaredGenuiNodes(
|
|
|
1432
1328
|
for (let row = 0; row < v.items.length; row++) {
|
|
1433
1329
|
walkNode(v.items[row], `${at}.items[${row}]`)
|
|
1434
1330
|
}
|
|
1331
|
+
} else if (v.type === 'table' && Array.isArray(v.details)) {
|
|
1332
|
+
const rowCount = tableRowsForDetails(v).length
|
|
1333
|
+
for (let row = 0; row < Math.min(v.details.length, rowCount); row++) {
|
|
1334
|
+
if (Array.isArray(v.details[row]) && isTableDetailReachable(v, row)) walk(v.details[row], `${at}.details[${row}]`)
|
|
1335
|
+
}
|
|
1435
1336
|
}
|
|
1436
1337
|
}
|
|
1437
1338
|
function walkItemsOf(holder: unknown, path: string): void {
|
|
@@ -1524,6 +1425,7 @@ export function validateCanonicalGenuiSpec(value: unknown): GenuiValidation {
|
|
|
1524
1425
|
}
|
|
1525
1426
|
}
|
|
1526
1427
|
walk(v.items, 0, 'items')
|
|
1428
|
+
if (errors.length === 0) errors.push(...analyzeSubmissionRegistry(v as unknown as GenuiSpec).diagnostics)
|
|
1527
1429
|
const uniqueErrors = [...new Set(errors)]
|
|
1528
1430
|
return { ok: uniqueErrors.length === 0, errors: uniqueErrors }
|
|
1529
1431
|
}
|
|
@@ -1590,6 +1492,9 @@ export function processGenuiSpec(value: unknown): GenuiProcessResult {
|
|
|
1590
1492
|
// unknown type. The processing pipeline is renderer-aware by contract:
|
|
1591
1493
|
// custom nodes stay opaque and must not fail native schema validation.
|
|
1592
1494
|
const errors = validation.errors.filter(error => !error.includes(': unknown type '))
|
|
1495
|
+
if (repaired !== null && errors.length === 0) {
|
|
1496
|
+
errors.push(...analyzeSubmissionRegistry(repaired).diagnostics)
|
|
1497
|
+
}
|
|
1593
1498
|
if (declaredNativeCount > renderedNativeCount) {
|
|
1594
1499
|
errors.push(`repair dropped ${declaredNativeCount - renderedNativeCount} declared native node(s): declared ${declaredNativeCount}, rendered ${renderedNativeCount}`)
|
|
1595
1500
|
}
|
|
@@ -1598,7 +1503,7 @@ export function processGenuiSpec(value: unknown): GenuiProcessResult {
|
|
|
1598
1503
|
normalized: normalized.value,
|
|
1599
1504
|
repaired,
|
|
1600
1505
|
spec: repaired,
|
|
1601
|
-
errors,
|
|
1506
|
+
errors: [...new Set(errors)],
|
|
1602
1507
|
warnings: [...normalized.warnings, ...diagnoseUnknownGenuiFields(normalized.value)],
|
|
1603
1508
|
// Compatibility fields retain their historical meanings: declaredCount
|
|
1604
1509
|
// is native declarations, while renderedCount is the total rendered tree.
|
|
@@ -1625,30 +1530,37 @@ export function isRenderableProcess(processed: GenuiProcessResult): boolean {
|
|
|
1625
1530
|
|
|
1626
1531
|
/* ---------------- partial fence rendering (issue #186) ---------------- */
|
|
1627
1532
|
|
|
1628
|
-
/** Longest
|
|
1629
|
-
const DECLARED_NODE_PATH_RE = /^(items\[\d+\](?:\.(?:items\[\d+\]|tabs\[\d+\]\.items\[\d+\]))*)/
|
|
1533
|
+
/** Longest node or aligned table-detail slot path a validation error points at. */
|
|
1534
|
+
const DECLARED_NODE_PATH_RE = /^(items\[\d+\](?:\.(?:items\[\d+\]|tabs\[\d+\]\.items\[\d+\]|details\[\d+\](?:\[\d+\])?))*)/
|
|
1630
1535
|
|
|
1631
1536
|
function errorNodePath(error: string): string | null {
|
|
1632
1537
|
const match = DECLARED_NODE_PATH_RE.exec(error)
|
|
1633
1538
|
return match === null ? null : match[1] ?? null
|
|
1634
1539
|
}
|
|
1635
1540
|
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1541
|
+
interface NodeSlot {
|
|
1542
|
+
array: unknown[]
|
|
1543
|
+
index: number
|
|
1544
|
+
/** Detail row slots align with table rows; pruning must not shift them. */
|
|
1545
|
+
preserveIndex: boolean
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** Where the node or table-detail slot at a validation path lives. */
|
|
1549
|
+
function nodeSlotAt(root: Record<string, unknown>, path: string): NodeSlot | undefined {
|
|
1550
|
+
const steps = [...path.matchAll(/(?:^|\.)(items|tabs|details)\[(\d+)\]|\[(\d+)\]/g)]
|
|
1551
|
+
if (steps.length === 0 || steps[steps.length - 1]![1] === 'tabs') return undefined
|
|
1640
1552
|
let current: unknown = root
|
|
1641
|
-
for (let i = 0; i < steps.length
|
|
1553
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1642
1554
|
const step = steps[i]!
|
|
1643
|
-
|
|
1644
|
-
const list =
|
|
1645
|
-
|
|
1555
|
+
// A detail child has a second index without a property: details[row][node].
|
|
1556
|
+
const list = step[1] === undefined ? current : obj(current)?.[step[1]]
|
|
1557
|
+
const index = Number(step[2] ?? step[3])
|
|
1558
|
+
if (!Array.isArray(list) || index >= list.length) return undefined
|
|
1559
|
+
if (i === steps.length - 1) return { array: list, index, preserveIndex: step[1] === 'details' }
|
|
1560
|
+
current = list[index]
|
|
1646
1561
|
if (current === undefined) return undefined
|
|
1647
1562
|
}
|
|
1648
|
-
|
|
1649
|
-
const list = holder === undefined ? undefined : holder.items
|
|
1650
|
-
if (!Array.isArray(list)) return undefined
|
|
1651
|
-
return { array: list, index: Number(steps[steps.length - 1]![2]) }
|
|
1563
|
+
return undefined
|
|
1652
1564
|
}
|
|
1653
1565
|
|
|
1654
1566
|
/** Deep-clone a JSON value for pruning; null when it cannot round-trip. */
|
|
@@ -1676,18 +1588,21 @@ export function partialRepairGenuiSpec(processed: GenuiProcessResult): GenuiSpec
|
|
|
1676
1588
|
if (isRenderableProcess(processed)) return processed.spec
|
|
1677
1589
|
if (processed.spec === null) return null
|
|
1678
1590
|
const root = obj(processed.value)
|
|
1679
|
-
|
|
1680
|
-
// are wrap-relative (`items[0]` is the root itself after wrapping).
|
|
1681
|
-
if (root === undefined || isComponentRoot(root)) return null
|
|
1682
|
-
if (processed.declaredNativeCount <= 1) return null
|
|
1591
|
+
if (root === undefined) return null
|
|
1683
1592
|
const paths = new Set<string>()
|
|
1593
|
+
const overhangPaths = new Set<string>()
|
|
1684
1594
|
for (const error of processed.errors) {
|
|
1685
1595
|
if (error.startsWith('spec exceeds ')) continue
|
|
1686
1596
|
const nodePath = errorNodePath(error)
|
|
1687
|
-
if (nodePath
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1597
|
+
if (nodePath === null) continue
|
|
1598
|
+
if (error === `${nodePath}.details must not contain more entries than rows`) {
|
|
1599
|
+
overhangPaths.add(nodePath)
|
|
1600
|
+
} else paths.add(nodePath)
|
|
1601
|
+
}
|
|
1602
|
+
if (paths.size === 0 && overhangPaths.size === 0) return null
|
|
1603
|
+
// Validation wraps a bare component first. Prune the same shape so its
|
|
1604
|
+
// detail paths resolve even when the table is the only declared node.
|
|
1605
|
+
const pruned = cloneJsonValue(isComponentRoot(root) ? wrapSingleComponentRoot(root) : root)
|
|
1691
1606
|
if (pruned === null) return null
|
|
1692
1607
|
// Resolve every slot BEFORE the first splice: each drop shifts the later
|
|
1693
1608
|
// siblings of the same array, so a resolve-after-splice (deepest-first or
|
|
@@ -1697,9 +1612,24 @@ export function partialRepairGenuiSpec(processed: GenuiProcessResult): GenuiSpec
|
|
|
1697
1612
|
// highest-index first keeps the remaining indexes valid (issue #190).
|
|
1698
1613
|
const slots = [...paths]
|
|
1699
1614
|
.map((path) => nodeSlotAt(pruned, path))
|
|
1700
|
-
.filter((slot): slot is
|
|
1615
|
+
.filter((slot): slot is NodeSlot => slot !== undefined)
|
|
1616
|
+
const overhangSlots = [...overhangPaths]
|
|
1617
|
+
.map((path) => nodeSlotAt(pruned, path))
|
|
1618
|
+
.filter((slot): slot is NodeSlot => slot !== undefined)
|
|
1619
|
+
// Extra details are unreachable. Mirror table repair's existing truncation
|
|
1620
|
+
// only for this exact diagnostic, then validate the whole candidate again.
|
|
1621
|
+
for (const { array, index } of overhangSlots) {
|
|
1622
|
+
const table = obj(array[index])
|
|
1623
|
+
if (table?.type === 'table' && Array.isArray(table.details)) {
|
|
1624
|
+
table.details.length = Math.min(table.details.length, tableRowsForDetails(table).length)
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1701
1627
|
const byArray = new Map<unknown[], number[]>()
|
|
1702
|
-
for (const { array, index } of slots) {
|
|
1628
|
+
for (const { array, index, preserveIndex } of slots) {
|
|
1629
|
+
if (preserveIndex) {
|
|
1630
|
+
array[index] = null
|
|
1631
|
+
continue
|
|
1632
|
+
}
|
|
1703
1633
|
const indexes = byArray.get(array)
|
|
1704
1634
|
if (indexes === undefined) byArray.set(array, [index])
|
|
1705
1635
|
else indexes.push(index)
|
|
@@ -1864,9 +1794,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1864
1794
|
break
|
|
1865
1795
|
case 'submit':
|
|
1866
1796
|
if (typeof v.label !== 'string') errors.push(`${at}: type 'submit' requires label (string)`)
|
|
1867
|
-
// action
|
|
1868
|
-
// renderer disables the button when it is absent AND no question
|
|
1869
|
-
// carries local `answer` data.
|
|
1797
|
+
// 本地判卷可以不设置 action;不满足判卷条件时,渲染器会禁用无 action 的按钮。
|
|
1870
1798
|
break
|
|
1871
1799
|
case 'badge':
|
|
1872
1800
|
if (typeof v.label !== 'string' && typeof v.text !== 'string' && typeof v.value !== 'string') {
|
|
@@ -1920,6 +1848,20 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
|
|
|
1920
1848
|
if (v.details !== undefined && !Array.isArray(v.details)) {
|
|
1921
1849
|
errors.push(`${at}.details must be an array aligned with rows`)
|
|
1922
1850
|
}
|
|
1851
|
+
if (Array.isArray(v.details)) {
|
|
1852
|
+
const table = { columns: v.columns, rows: v.rows, types: v.types }
|
|
1853
|
+
const rowCount = tableRowsForDetails(table).length
|
|
1854
|
+
if (v.details.length > rowCount) errors.push(`${at}.details must not contain more entries than rows`)
|
|
1855
|
+
for (let i = 0; i < Math.min(v.details.length, rowCount); i++) {
|
|
1856
|
+
const detail = v.details[i]
|
|
1857
|
+
if (detail === null || !isTableDetailReachable(table, i)) continue
|
|
1858
|
+
if (!Array.isArray(detail)) {
|
|
1859
|
+
errors.push(`${at}.details[${i}] must be an array or null`)
|
|
1860
|
+
continue
|
|
1861
|
+
}
|
|
1862
|
+
walk(detail, depth + 1, `${at}.details[${i}]`)
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1923
1865
|
validateTableRows(v.rows, `${at}.rows`, errors)
|
|
1924
1866
|
break
|
|
1925
1867
|
case 'chart':
|
package/src/client/inline.ts
CHANGED
|
@@ -20,9 +20,13 @@ function InlineMath({ source, display }: { source: string; display: boolean }) {
|
|
|
20
20
|
|
|
21
21
|
// Code is literal. TeX tokens are opaque to emphasis/link parsing; the other
|
|
22
22
|
// rich-text tokens recurse so **$x$** and ==\\(x\\)== work without nested DOM roots.
|
|
23
|
-
// A real newline in the string
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// A real newline in the string stays a REAL newline in the DOM — not a `<br>`.
|
|
24
|
+
// A `<br>` paints a visual break but contributes nothing to `textContent`, so
|
|
25
|
+
// selecting and copying a multi-line cell yielded one run-on line: a pasted
|
|
26
|
+
// `python - <<'PY' … PY` heredoc lost its line structure and could no longer
|
|
27
|
+
// run. Real newlines survive both the paint and the copy path; containers that
|
|
28
|
+
// can carry them declare `white-space: pre-line` (prose) or `pre-wrap` (code).
|
|
29
|
+
// No HTML parsing happens here, only the model's `\n`.
|
|
26
30
|
const INLINE = /(?<!`)`[^`\n]+`(?!`)|\\\\|\\\$|(?<![\\$])\$\$(?:\\.|[^\\])*?\$\$|\\\[(?:\\(?!\])[^]|[^\\])*?\\\]|\\\((?:\\(?!\))[^]|[^\\])*?\\\)|(?<![\\$])\$(?!\s|\$)(?:\\.|[^$\\\n])+(?<!\s)\$(?!\d|\$)|\*\*[\s\S]+?\*\*|==[\s\S]+?==|\[[^\]\n]+\]\([^)\s]+\)|\r?\n/g
|
|
27
31
|
const FENCE_MARKER = /`{3,}|~{3,}/g
|
|
28
32
|
|
|
@@ -65,7 +69,10 @@ export function renderInline(text: string, allowLinks = true, depth = 0): ReactN
|
|
|
65
69
|
const token = match[0]
|
|
66
70
|
if (index > last) out.push(text.slice(last, index))
|
|
67
71
|
if (token === '\n' || token === '\r\n') {
|
|
68
|
-
|
|
72
|
+
// Keep the newline character itself: it is what `textContent`,
|
|
73
|
+
// `Selection.toString()` and the clipboard carry. `pre-line`/`pre-wrap`
|
|
74
|
+
// on the owning container turns it into a visible line break.
|
|
75
|
+
out.push('\n')
|
|
69
76
|
} else if (token.startsWith('`')) {
|
|
70
77
|
out.push(createElement('code', { key: key++, className: css.inlineCode }, token.slice(1, -1)))
|
|
71
78
|
} else if (token === '\\$' || token === '\\\\') {
|
package/src/client/spec.ts
CHANGED
|
@@ -471,17 +471,13 @@ export interface GenuiRadio {
|
|
|
471
471
|
explanation?: string
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
/**
|
|
475
|
-
* this block. LOCAL-FIRST grading still applies to radio-only question scopes;
|
|
476
|
-
* aggregation scopes can emit strings for radio groups and string arrays for
|
|
477
|
-
* checkbox groups in the same `answers` object. */
|
|
474
|
+
/** 提交节点收集当前 block 的答案和字段值;纯 radio 范围可在本地判卷。 */
|
|
478
475
|
export interface GenuiSubmit {
|
|
479
476
|
type: 'submit'
|
|
480
477
|
label: string
|
|
481
478
|
/**
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
* an action and emit `{type:'submit', answers, total, answered}`.
|
|
479
|
+
* 可选 action 名称。纯 radio 范围满足本地判卷条件时无需 action;其他提交
|
|
480
|
+
* 通过 action 发送 `answers`、可选的 `fields`、`total` 和 `answered`。
|
|
485
481
|
*/
|
|
486
482
|
action?: string
|
|
487
483
|
/**
|
|
@@ -491,10 +487,11 @@ export interface GenuiSubmit {
|
|
|
491
487
|
*/
|
|
492
488
|
resetAction?: string
|
|
493
489
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
490
|
+
* `groups` 引用当前 block 中的 submission member key,来源为 radio.group、
|
|
491
|
+
* checkbox.group 以及 input/textarea/select/slider.id。radio 需已选择,
|
|
492
|
+
* checkbox 需至少选择一项,普通 field 需在 trim 后非空。`groups` 控制提交
|
|
493
|
+
* 所需成员与完成进度;payload 仍收集当前 block 中已填写的表单状态。
|
|
494
|
+
* 未设置时,只要存在至少一个已完成成员即可提交。
|
|
498
495
|
*/
|
|
499
496
|
groups?: string[]
|
|
500
497
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { GENUI_LIMITS } from './genui-runtime/index.ts'
|
|
2
|
+
import type { GenuiSpec } from './spec.ts'
|
|
3
|
+
import { walkGenuiNodes } from './walk-spec.ts'
|
|
4
|
+
|
|
5
|
+
export type SubmissionMember = RadioSubmissionMember | CheckboxSubmissionMember | FieldSubmissionMember
|
|
6
|
+
|
|
7
|
+
export interface RadioSubmissionMember {
|
|
8
|
+
kind: 'radio'
|
|
9
|
+
key: string
|
|
10
|
+
label: string
|
|
11
|
+
options: string[]
|
|
12
|
+
answer?: number | string
|
|
13
|
+
explanation?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CheckboxSubmissionMember {
|
|
17
|
+
kind: 'checkbox'
|
|
18
|
+
key: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FieldSubmissionMember {
|
|
22
|
+
kind: 'field'
|
|
23
|
+
key: string
|
|
24
|
+
fieldType: 'input' | 'textarea' | 'select' | 'slider'
|
|
25
|
+
secret: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SubmissionRegistry {
|
|
29
|
+
members: ReadonlyMap<string, SubmissionMember>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SubmitInteractionState {
|
|
33
|
+
answers: Record<string, string>
|
|
34
|
+
multiAnswers: Record<string, string[]>
|
|
35
|
+
fields: Record<string, string>
|
|
36
|
+
secretFields: ReadonlySet<string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResolvedSubmitState {
|
|
40
|
+
scope: SubmissionMember[]
|
|
41
|
+
answered: number
|
|
42
|
+
total: number
|
|
43
|
+
localGradeEligible: boolean
|
|
44
|
+
hasOutOfScopePayload: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 从 spec 建立成员表,并诊断 submission key 和 submit.groups。 */
|
|
48
|
+
export function analyzeSubmissionRegistry(spec: GenuiSpec): { registry: SubmissionRegistry; diagnostics: string[] } {
|
|
49
|
+
const members = new Map<string, SubmissionMember>()
|
|
50
|
+
const paths = new Map<string, string>()
|
|
51
|
+
const diagnostics: string[] = []
|
|
52
|
+
const submits: Array<{ groups: string[]; path: string }> = []
|
|
53
|
+
|
|
54
|
+
walkGenuiNodes(spec, (node, path) => {
|
|
55
|
+
if (node.type === 'submit') {
|
|
56
|
+
if (node.groups !== undefined) submits.push({ groups: node.groups, path })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
let member: SubmissionMember | undefined
|
|
60
|
+
let keyPath: string
|
|
61
|
+
switch (node.type) {
|
|
62
|
+
case 'radio':
|
|
63
|
+
if (node.group === undefined) return
|
|
64
|
+
member = { kind: 'radio', key: node.group, label: node.label ?? node.group, options: node.options.slice(0, GENUI_LIMITS.maxOptions), ...(node.answer === undefined ? {} : { answer: node.answer }), ...(node.explanation === undefined ? {} : { explanation: node.explanation }) }
|
|
65
|
+
keyPath = `${path}.group`
|
|
66
|
+
break
|
|
67
|
+
case 'checkbox':
|
|
68
|
+
if (node.group === undefined) return
|
|
69
|
+
member = { kind: 'checkbox', key: node.group }
|
|
70
|
+
keyPath = `${path}.group`
|
|
71
|
+
break
|
|
72
|
+
case 'input':
|
|
73
|
+
case 'textarea':
|
|
74
|
+
case 'select':
|
|
75
|
+
case 'slider':
|
|
76
|
+
if (node.id === undefined) return
|
|
77
|
+
member = { kind: 'field', key: node.id, fieldType: node.type, secret: node.type === 'input' && node.inputType === 'password' }
|
|
78
|
+
keyPath = `${path}.id`
|
|
79
|
+
break
|
|
80
|
+
default:
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (member === undefined) return
|
|
84
|
+
const existing = members.get(member.key)
|
|
85
|
+
if (existing !== undefined) {
|
|
86
|
+
if (existing.kind !== 'checkbox' || member.kind !== 'checkbox') {
|
|
87
|
+
diagnostics.push(`${keyPath} conflicts with ${paths.get(member.key)}: submission key '${member.key}' is already used`)
|
|
88
|
+
}
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
members.set(member.key, member)
|
|
92
|
+
paths.set(member.key, keyPath)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
for (const { groups, path } of submits) {
|
|
96
|
+
const seen = new Set<string>()
|
|
97
|
+
groups.forEach((key, index) => {
|
|
98
|
+
const at = `${path}.groups[${index}]`
|
|
99
|
+
if (seen.has(key)) diagnostics.push(`${at}: duplicate submission member '${key}'`)
|
|
100
|
+
seen.add(key)
|
|
101
|
+
const member = members.get(key)
|
|
102
|
+
if (member === undefined) diagnostics.push(`${at}: submit.groups references unknown submission member '${key}'`)
|
|
103
|
+
else if (member.kind === 'field' && member.secret) diagnostics.push(`${at}: submit.groups cannot require secret field '${key}'`)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return { registry: { members }, diagnostics }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 编译供运行时读取的静态 submission 成员表。 */
|
|
110
|
+
export function compileSubmissionRegistry(spec: GenuiSpec): SubmissionRegistry {
|
|
111
|
+
return analyzeSubmissionRegistry(spec).registry
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 根据当前交互状态判断一个 submission member 是否完成。 */
|
|
115
|
+
export function isSubmissionMemberAnswered(member: SubmissionMember, state: SubmitInteractionState): boolean {
|
|
116
|
+
switch (member.kind) {
|
|
117
|
+
case 'radio': return state.answers[member.key] !== undefined
|
|
118
|
+
case 'checkbox': return (state.multiAnswers[member.key]?.length ?? 0) > 0
|
|
119
|
+
case 'field': {
|
|
120
|
+
if (member.secret) return false
|
|
121
|
+
const value = state.fields[member.key]
|
|
122
|
+
return value !== undefined && value.trim() !== ''
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 计算 submit 进度和纯本地判卷条件。 */
|
|
128
|
+
export function resolveSubmitState({ registry, groups, state }: {
|
|
129
|
+
registry: SubmissionRegistry
|
|
130
|
+
groups?: string[]
|
|
131
|
+
state: SubmitInteractionState
|
|
132
|
+
}): ResolvedSubmitState {
|
|
133
|
+
const hasUnknownMember = groups?.some(key => !registry.members.has(key)) ?? false
|
|
134
|
+
const scope = groups === undefined
|
|
135
|
+
? [...registry.members.values()].filter(member => isSubmissionMemberAnswered(member, state))
|
|
136
|
+
: groups.flatMap(key => {
|
|
137
|
+
const member = registry.members.get(key)
|
|
138
|
+
return member === undefined ? [] : [member]
|
|
139
|
+
})
|
|
140
|
+
const answered = scope.filter(member => isSubmissionMemberAnswered(member, state)).length
|
|
141
|
+
const total = groups?.length ?? scope.length
|
|
142
|
+
const scopeKeys = new Set(scope.map(member => member.key))
|
|
143
|
+
const hasOutOfScopePayload = Object.keys(state.answers).some(key => !scopeKeys.has(key))
|
|
144
|
+
|| Object.keys(state.multiAnswers).some(key => !scopeKeys.has(key))
|
|
145
|
+
|| Object.entries(state.fields).some(([key, value]) => value.trim() !== '' && !state.secretFields.has(key) && !scopeKeys.has(key))
|
|
146
|
+
const localGradeEligible = !hasUnknownMember && scope.length > 0
|
|
147
|
+
&& scope.every(member => member.kind === 'radio')
|
|
148
|
+
&& scope.some(member => member.kind === 'radio' && member.answer !== undefined)
|
|
149
|
+
return { scope, answered, total, localGradeEligible, hasOutOfScopePayload }
|
|
150
|
+
}
|