@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.
@@ -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
- let rawCols = v.columns as unknown
422
- let rawRows = v.rows !== undefined ? v.rows : (v as Record<string, unknown>).data
423
- // Self-heal model-shaped tables: antd-style object columns
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 is OPTIONAL: local grading (any question carries `answer`)
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 declared-node path prefix a validation error points at. */
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
- /** Where the node at a declared path lives: its parent array and index. */
1637
- function nodeSlotAt(root: Record<string, unknown>, path: string): { array: unknown[]; index: number } | undefined {
1638
- const steps = [...path.matchAll(/(?:^|\.)(items|tabs)\[(\d+)\]/g)]
1639
- if (steps.length === 0 || steps[steps.length - 1]![1] !== 'items') return undefined
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 - 1; i++) {
1553
+ for (let i = 0; i < steps.length; i++) {
1642
1554
  const step = steps[i]!
1643
- const holder = obj(current)
1644
- const list = holder === undefined ? undefined : holder[step[1]!]
1645
- current = Array.isArray(list) ? list[Number(step[2])] : undefined
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
- const holder = obj(current)
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
- // A bare component root has no siblings to keep, and its validation paths
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 !== null) paths.add(nodePath)
1688
- }
1689
- if (paths.size === 0) return null
1690
- const pruned = cloneJsonValue(processed.value)
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 { array: unknown[]; index: number } => slot !== undefined)
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 is optional (local grading needs no round trip); the
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':
@@ -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 is its own token rendered as <br>, so text
24
- // fields express a line break via JSON "\n" — no HTML parsing, and the
25
- // single-line (nowrap) chrome classes never contain one.
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
- out.push(createElement('br', { key: key++ }))
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 === '\\\\') {
@@ -471,17 +471,13 @@ export interface GenuiRadio {
471
471
  explanation?: string
472
472
  }
473
473
 
474
- /** Submit node: collects sibling `radio` and grouped `checkbox` answers in
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
- * Optional action name. Local-first: when the in-scope questions can be
483
- * graded entirely locally, no action is needed. Aggregation submits require
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
- * Optional explicit group list to wait for; when absent the submit enables
495
- * once at least one grouped answer (or filled field) exists. When present
496
- * it stays disabled until EVERY listed radio/checkbox group has a non-empty
497
- * recorded answer (the hint shows the progress).
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
+ }