@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +21 -6
  3. package/README.zh-CN.md +21 -6
  4. package/SKILL.md +18 -6
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +10 -9
  7. package/lib/client.js +31 -33
  8. package/lib/index.js +1060 -371
  9. package/lib/types/client/blocks/forms.d.ts +5 -6
  10. package/lib/types/client/blocks/state.d.ts +2 -11
  11. package/lib/types/client/dom-fence.d.ts +2 -1
  12. package/lib/types/client/file-address.d.ts +4 -0
  13. package/lib/types/client/file-link-context.d.ts +41 -0
  14. package/lib/types/client/file-link.d.ts +17 -0
  15. package/lib/types/client/i18n/en.d.ts +5 -1
  16. package/lib/types/client/inline.d.ts +1 -1
  17. package/lib/types/client/mermaid-colors.d.ts +2 -0
  18. package/lib/types/client/panel.d.ts +6 -1
  19. package/lib/types/client/spec.d.ts +8 -11
  20. package/lib/types/client/submission-registry.d.ts +51 -0
  21. package/lib/types/client/table-details.d.ts +20 -0
  22. package/lib/types/client/walk-spec.d.ts +3 -0
  23. package/lib/types/plugin/fence-feedback.d.ts +55 -12
  24. package/lib/types/plugin/index.d.ts +7 -3
  25. package/lib/types/shared/fence-repair.d.ts +19 -7
  26. package/package.json +1 -12
  27. package/src/client/GenuiBlock.module.css +100 -4
  28. package/src/client/GenuiBlock.tsx +7 -14
  29. package/src/client/artifact/ArtifactExport.module.css +34 -3
  30. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  31. package/src/client/blocks/charts.tsx +63 -14
  32. package/src/client/blocks/forms.tsx +26 -48
  33. package/src/client/blocks/render-node.tsx +11 -8
  34. package/src/client/blocks/state.ts +2 -12
  35. package/src/client/dom-fence.tsx +190 -50
  36. package/src/client/file-address.ts +37 -0
  37. package/src/client/file-link-context.tsx +114 -0
  38. package/src/client/file-link.ts +46 -0
  39. package/src/client/genui-runtime/diagnostics.ts +20 -0
  40. package/src/client/genui-runtime/normalize.ts +28 -9
  41. package/src/client/guard.ts +98 -153
  42. package/src/client/i18n/en.ts +5 -1
  43. package/src/client/i18n/zh.ts +5 -1
  44. package/src/client/index.tsx +23 -5
  45. package/src/client/inline.ts +28 -10
  46. package/src/client/mermaid-colors.ts +39 -0
  47. package/src/client/mermaid-core.ts +7 -3
  48. package/src/client/panel.tsx +18 -5
  49. package/src/client/spec.ts +8 -11
  50. package/src/client/submission-registry.ts +150 -0
  51. package/src/client/table-details.ts +151 -0
  52. package/src/client/walk-spec.ts +43 -0
  53. package/src/plugin/fence-feedback.ts +384 -53
  54. package/src/plugin/index.ts +20 -9
  55. package/src/plugin/tool.ts +8 -2
  56. package/src/shared/fence-repair.ts +345 -128
  57. package/tsdown.config.ts +3 -7
  58. package/lib/invariant.js +0 -19
  59. package/lib/types/plugin/invariant.d.ts +0 -15
  60. package/src/plugin/invariant.ts +0 -30
@@ -0,0 +1,114 @@
1
+ import { createContext, createElement, useContext, useMemo, useSyncExternalStore, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
2
+ import { fileAddressFor } from './file-address.ts'
3
+ import { resolveViewedSessionId } from './session-resolver.ts'
4
+ import css from './GenuiBlock.module.css'
5
+
6
+ interface SidebarRightService {
7
+ openResource(address: string, options?: { params: { line: number } }): void
8
+ }
9
+
10
+ interface FileLinkHostContext {
11
+ get(name: 'sidebarRight', strict?: false): SidebarRightService | undefined
12
+ sessions: {
13
+ list: { getSnapshot(): unknown }
14
+ }
15
+ }
16
+
17
+ interface FileLinkScope {
18
+ get(name: 'sidebarRight'): SidebarRightService | undefined
19
+ effect(callback: () => void | (() => void), name?: string): unknown
20
+ }
21
+
22
+ export interface FileLinkCordisContext extends FileLinkHostContext {
23
+ inject(services: ['sidebarRight'], callback: (scope: FileLinkScope) => void): unknown
24
+ }
25
+
26
+ export interface FileLinkAvailability {
27
+ subscribe(listener: () => void): () => void
28
+ getSnapshot(): boolean
29
+ }
30
+
31
+ const FileLinkOpenContext = createContext<((path: string, line?: number) => void) | undefined>(undefined)
32
+
33
+ /** Track an optional host service through arrival, removal, and plugin unload. */
34
+ export function createFileLinkAvailability(ctx: FileLinkCordisContext): FileLinkAvailability {
35
+ let available = ctx.get('sidebarRight', false)?.openResource !== undefined
36
+ const listeners = new Set<() => void>()
37
+ const notify = (): void => {
38
+ for (const listener of listeners) listener()
39
+ }
40
+ const setAvailable = (next: boolean): void => {
41
+ if (available === next) return
42
+ available = next
43
+ notify()
44
+ }
45
+
46
+ ctx.inject(['sidebarRight'], (scope) => {
47
+ scope.effect(() => {
48
+ setAvailable(scope.get('sidebarRight')?.openResource !== undefined)
49
+ return () => setAvailable(false)
50
+ }, 'genui: file-link sidebarRight')
51
+ })
52
+
53
+ return {
54
+ subscribe(listener) {
55
+ listeners.add(listener)
56
+ return () => { listeners.delete(listener) }
57
+ },
58
+ getSnapshot: () => available,
59
+ }
60
+ }
61
+
62
+ /** Provide one fence's session-scoped file opener to descendant components. */
63
+ function FileLinkProvider({ ctx, availability, sessionId, streaming, children }: {
64
+ ctx: FileLinkHostContext
65
+ availability: FileLinkAvailability
66
+ sessionId: string | undefined
67
+ streaming: boolean
68
+ children?: ReactNode
69
+ }) {
70
+ const available = useSyncExternalStore(availability.subscribe, availability.getSnapshot, () => false)
71
+ const open = useMemo(() => {
72
+ if (!available || streaming || sessionId === undefined) return undefined
73
+ return (path: string, line?: number): void => {
74
+ const snapshot = ctx.sessions.list.getSnapshot()
75
+ if (resolveViewedSessionId(snapshot) !== sessionId) return
76
+ const sessions = snapshot as { byId?: Record<string, { cwd?: string } | undefined> }
77
+ const cwd = sessions.byId?.[sessionId]?.cwd
78
+ const address = fileAddressFor(sessionId, cwd, path)
79
+ const sidebar = ctx.get('sidebarRight', false)
80
+ if (sidebar?.openResource === undefined) return
81
+ if (line === undefined) sidebar.openResource(address)
82
+ else sidebar.openResource(address, { params: { line } })
83
+ }
84
+ }, [available, ctx, sessionId, streaming])
85
+
86
+ return createElement(FileLinkOpenContext.Provider, { value: open }, children)
87
+ }
88
+
89
+ /** Render a file link as an accessible host navigation button when available. */
90
+ export function FileLink({ path, line, label, children }: { path: string; line?: number; label: string; children?: ReactNode }) {
91
+ const open = useContext(FileLinkOpenContext)
92
+ if (open === undefined) return createElement('span', { title: path }, children)
93
+ return createElement('button', {
94
+ type: 'button',
95
+ className: css.inlineFileLink,
96
+ title: path,
97
+ 'aria-label': `${label} (${path})`,
98
+ onClick: (event: ReactMouseEvent<HTMLButtonElement>) => {
99
+ event.stopPropagation()
100
+ open(path, line)
101
+ },
102
+ }, children)
103
+ }
104
+
105
+ /** Add session-scoped file navigation to one fence render tree. */
106
+ export function withFileLinkContext(
107
+ ctx: FileLinkHostContext,
108
+ availability: FileLinkAvailability,
109
+ sessionId: string | undefined,
110
+ streaming: boolean,
111
+ children: ReactNode,
112
+ ): ReactNode {
113
+ return createElement(FileLinkProvider, { ctx, availability, sessionId, streaming, children })
114
+ }
@@ -0,0 +1,46 @@
1
+ import { safeHref } from './genui-runtime/value-utils.ts'
2
+
3
+ export interface ParsedFileLink {
4
+ path: string
5
+ line?: number
6
+ }
7
+
8
+ export type LinkTarget =
9
+ | { type: 'external'; href: string }
10
+ | { type: 'file'; file: ParsedFileLink }
11
+ | { type: 'invalid' }
12
+
13
+ /** Decode a local file destination and its optional first line fragment. */
14
+ export function parseFileLink(value: string): ParsedFileLink | undefined {
15
+ if (value.length > 2048) return undefined
16
+ const hash = value.indexOf('#')
17
+ const destination = hash < 0 ? value : value.slice(0, hash)
18
+ if (destination.includes('?')) return undefined
19
+
20
+ let path: string
21
+ try {
22
+ path = decodeURIComponent(destination)
23
+ } catch {
24
+ return undefined
25
+ }
26
+
27
+ if (path === '' || /[\u0000-\u001f\u007f]/.test(path) || /^[\\/]{2}/.test(path)
28
+ || (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path))) return undefined
29
+ if (hash < 0) return { path }
30
+
31
+ const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(value.slice(hash + 1))
32
+ if (match === null) return undefined
33
+ const line = Number(match[1])
34
+ const end = match[2] === undefined ? line : Number(match[2])
35
+ if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return undefined
36
+ return { path, line }
37
+ }
38
+
39
+ /** Classify a link destination while preserving the existing external URL policy. */
40
+ export function classifyLinkTarget(value: unknown): LinkTarget {
41
+ const external = safeHref(value)
42
+ if (external !== undefined) return { type: 'external', href: external }
43
+ if (typeof value !== 'string') return { type: 'invalid' }
44
+ const file = parseFileLink(value.trim())
45
+ return file === undefined ? { type: 'invalid' } : { type: 'file', file }
46
+ }
@@ -2,6 +2,7 @@
2
2
  import { COMPONENT_SCHEMAS, GENUI_SPEC_SCHEMA } from './schema.ts'
3
3
  import { isComponentRoot } from '../spec.ts'
4
4
  import type { ComponentRecordSchema, ComponentSchema } from './schema.ts'
5
+ import { isTableDetailReachable } from '../table-details.ts'
5
6
 
6
7
  export interface GenuiDiagnostic {
7
8
  readonly kind: 'alias' | 'unknown-field'
@@ -47,6 +48,13 @@ function visitNativeNodes(value: unknown, path: string, visit: (node: Record<str
47
48
  const holder = record(item)
48
49
  if (holder?.items !== undefined && Array.isArray(holder.items)) holder.items.forEach((child, childIndex) => children(child, `${path}.items[${index}].items[${childIndex}]`))
49
50
  })
51
+ } else if (type === 'table' && Array.isArray(value.details)) {
52
+ const table = { columns: value.columns, rows: value.rows, types: value.types }
53
+ value.details.forEach((detail, rowIndex) => {
54
+ if (Array.isArray(detail) && isTableDetailReachable(table, rowIndex)) {
55
+ detail.forEach((child, childIndex) => children(child, `${path}.details[${rowIndex}][${childIndex}]`))
56
+ }
57
+ })
50
58
  }
51
59
  }
52
60
 
@@ -123,6 +131,18 @@ export function diagnoseUnknownGenuiFields(value: unknown): GenuiDiagnostic[] {
123
131
  pushUnknownField(warnings, path, field, node.type as string)
124
132
  }
125
133
  diagnoseNestedFields(node, path, definition, warnings)
134
+ if (node.type === 'list' && Array.isArray(node.items)) {
135
+ node.items.forEach((item, index) => {
136
+ if (isNode(item)) return
137
+ const holder = record(item)
138
+ if (holder === undefined) return
139
+ for (const field of Object.keys(holder)) {
140
+ if (field === 'title' || field === 'desc') continue
141
+ const itemPath = `${path}.items[${index}]`
142
+ warnings.push({ kind: 'unknown-field', path: `${itemPath}.${field}`, message: `${itemPath}.${field}: list items render 'title' and 'desc' only — '${field}' is dropped`, type: 'list', field })
143
+ }
144
+ })
145
+ }
126
146
  }
127
147
  if (Array.isArray(root.items) && !isComponentRoot(root)) {
128
148
  for (const field of Object.keys(root)) {
@@ -3,6 +3,7 @@ import { COMPONENT_SCHEMAS } from './schema.ts'
3
3
  import type { ComponentRecordSchema } from './schema.ts'
4
4
  import { isComponentRoot } from '../spec.ts'
5
5
  import type { GenuiDiagnostic } from './diagnostics.ts'
6
+ import { isTableDetailReachable } from '../table-details.ts'
6
7
 
7
8
  function record(value: unknown): Record<string, unknown> | undefined {
8
9
  return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -17,6 +18,7 @@ function isNode(value: unknown): value is Record<string, unknown> {
17
18
 
18
19
  /** Fields a `stat` keeps when a metric record is folded into its own node. */
19
20
  const STAT_METRIC_FIELDS = ['label', 'value', 'delta', 'spark', 'size'] as const
21
+ const LIST_ITEM_ALIASES = { label: 'title', name: 'title', description: 'desc', content: 'desc', text: 'desc', body: 'desc', detail: 'desc' } as const
20
22
 
21
23
  /**
22
24
  * Normalize one metric record of a stat group into a single-metric `stat`
@@ -187,20 +189,31 @@ function normalizeNode(value: unknown, path: string, warnings: GenuiDiagnostic[]
187
189
  if (type === 'row' || type === 'col' || type === 'grid' || type === 'card' || type === 'file-tree' || type === 'timeline' || type === 'breadcrumb') {
188
190
  if (type !== 'file-tree' && type !== 'timeline' && type !== 'breadcrumb') out.items = normalizeNodeArray(out.items, `${path}.items`)
189
191
  } else if (type === 'list' && Array.isArray(out.items)) {
190
- // List items are a union (string | {title,desc} | nested node), and models
191
- // routinely dump 1×N / N×1 table cells in place of the item: `[["文本"]]`
192
- // renders as an EMPTY list and `{title, description}` loses its body
193
- // (repair reads `desc`). Both are pure shape defects — normalize them here
194
- // so validation, diagnostics, and repair all see the canonical item.
192
+ // List records and nested nodes share items; aliases only belong to records.
195
193
  out.items = out.items.map((child, index) => {
196
194
  if (isNode(child)) return normalizeNodeValue(child, `${path}.items[${index}]`)
197
195
  if (Array.isArray(child) && child.length === 1 && typeof child[0] === 'string') return child[0]
198
196
  const holder = record(child)
199
- if (holder === undefined || !('description' in holder)) return holder === undefined ? child : { ...holder }
197
+ if (holder === undefined) return child
200
198
  const normalizedHolder = { ...holder }
201
- const description = normalizedHolder.description
202
- delete normalizedHolder.description
203
- if (!('desc' in normalizedHolder) && typeof description === 'string') normalizedHolder.desc = description
199
+ const itemPath = `${path}.items[${index}]`
200
+ for (const [alias, canonical] of Object.entries(LIST_ITEM_ALIASES)) {
201
+ if (!(alias in normalizedHolder)) continue
202
+ const keptCanonical = canonical in normalizedHolder
203
+ if (!keptCanonical) normalizedHolder[canonical] = normalizedHolder[alias]
204
+ delete normalizedHolder[alias]
205
+ warnings.push({
206
+ kind: 'alias', path: `${itemPath}.${alias}`, type, field: alias, canonical,
207
+ message: keptCanonical
208
+ ? `${itemPath}.${alias} is ignored because canonical field '${canonical}' is present`
209
+ : `${itemPath}.${alias} normalized/adopted as '${canonical}'`,
210
+ })
211
+ }
212
+ if (!('title' in normalizedHolder) && typeof normalizedHolder.desc === 'string') {
213
+ normalizedHolder.title = normalizedHolder.desc
214
+ delete normalizedHolder.desc
215
+ warnings.push({ kind: 'alias', path: `${itemPath}.desc`, type, field: 'desc', canonical: 'title', message: `${itemPath}.desc normalized/adopted as 'title'` })
216
+ }
204
217
  return normalizedHolder
205
218
  })
206
219
  } else if (type === 'keyvalue' && Array.isArray(out.pairs)) {
@@ -249,6 +262,12 @@ function normalizeNode(value: unknown, path: string, warnings: GenuiDiagnostic[]
249
262
  if (holder === undefined) return item
250
263
  return { ...holder, items: Array.isArray(holder.items) ? holder.items.map((child, childIndex) => normalizeNodeValue(child, `${path}.items[${index}].items[${childIndex}]`)) : holder.items }
251
264
  })
265
+ } else if (type === 'table' && Array.isArray(out.details)) {
266
+ const table = { columns: out.columns, rows: out.rows, types: out.types }
267
+ out.details = out.details.map((detail, rowIndex) => {
268
+ if (!Array.isArray(detail) || !isTableDetailReachable(table, rowIndex)) return detail
269
+ return detail.map((child, childIndex) => normalizeNodeValue(child, `${path}.details[${rowIndex}][${childIndex}]`))
270
+ })
252
271
  }
253
272
  return out
254
273
  }
@@ -31,7 +31,10 @@ 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 { color, enu, int, num, obj, opt, safeHref, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
34
+ import { analyzeSubmissionRegistry } from './submission-registry.ts'
35
+ import { isTableDetailReachable, prepareTableRows, tableRowsForDetails } from './table-details.ts'
36
+ import { color, enu, int, num, obj, opt, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
37
+ import { classifyLinkTarget } from './file-link.ts'
35
38
 
36
39
  /** Result of `validateGenuiSpec`. */
37
40
  export interface GenuiValidation {
@@ -334,7 +337,9 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
334
337
  case 'link': {
335
338
  const label = str(v.label, GENUI_LIMITS.maxString)
336
339
  if (label === undefined) return null
337
- return { type: 'link', label, ...opt('href', safeHref(v.href)) }
340
+ const target = classifyLinkTarget(v.href)
341
+ const href = target.type === 'external' ? target.href : target.type === 'file' ? String(v.href).trim() : undefined
342
+ return { type: 'link', label, ...opt('href', href) }
338
343
  }
339
344
  case 'image': {
340
345
  const src = safeMediaSrc(v.src)
@@ -418,41 +423,9 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
418
423
  return { type: 'list', items, ...opt('filter', str(v.filter, 64)) }
419
424
  }
420
425
  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
426
+ const prepared = prepareTableRows(v)
427
+ if (prepared === null) return null
428
+ const { columns, rows } = prepared
456
429
  // Optional per-column cell types; unknown entries degrade to 'text'.
457
430
  const rawTypes = Array.isArray(v.types) ? v.types : undefined
458
431
  const types = rawTypes === undefined
@@ -470,6 +443,7 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
470
443
  const details = rawDetails === undefined
471
444
  ? undefined
472
445
  : rows.map((_row, i) => {
446
+ if (!isTableDetailReachable({ columns, rows, types }, i)) return null
473
447
  const entry = repairItems(rawDetails[i], ctx, depth + 1)
474
448
  return entry.length === 0 ? null : entry
475
449
  })
@@ -569,10 +543,7 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
569
543
  }
570
544
  case 'submit': {
571
545
  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).
546
+ // 本地判卷可以不设置 action;不满足判卷条件时,渲染器会禁用无 action 的按钮。
576
547
  const action = str(v.action, 200)
577
548
  if (label === undefined) return null
578
549
  return {
@@ -760,11 +731,6 @@ function repairListItems(
760
731
  continue
761
732
  }
762
733
  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
734
  if (o !== undefined && typeof o.type === 'string') {
769
735
  // Typed children are GenuiNodes: charge them against the shared node
770
736
  // budget (module header promise — exhausted budget elides remaining
@@ -774,58 +740,16 @@ function repairListItems(
774
740
  ctx.remaining -= 1
775
741
  const node = repairNode(o, ctx, depth)
776
742
  if (node !== null) out.push(node)
743
+ continue
744
+ }
745
+ const title = o === undefined ? undefined : str(o.title, GENUI_LIMITS.maxString)
746
+ if (title !== undefined) {
747
+ out.push({ title, ...opt('desc', str(o?.desc, GENUI_LIMITS.maxString)) })
777
748
  }
778
749
  }
779
750
  return out
780
751
  }
781
752
 
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
753
  function repairChartData(v: unknown, cap: number): Array<{ label: string; value: number; color?: string }> | undefined {
830
754
  if (!Array.isArray(v)) return undefined
831
755
  const out: Array<{ label: string; value: number; color?: string }> = []
@@ -872,38 +796,6 @@ function repairTabs(v: unknown, ctx: RepairCtx, depth: number): Array<{ label: s
872
796
  return out
873
797
  }
874
798
 
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
799
  function repairPlotSeries(v: unknown, cap: number): GenuiPlot['series'] | undefined {
908
800
  if (!Array.isArray(v)) return undefined
909
801
  const out: GenuiPlot['series'] = []
@@ -1376,6 +1268,13 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
1376
1268
  const lo = obj(li)
1377
1269
  if (lo !== undefined && typeof lo.type === 'string') walk([lo])
1378
1270
  }
1271
+ } else if (v.type === 'table' && Array.isArray(v.details)) {
1272
+ const rowCount = tableRowsForDetails(v).length
1273
+ for (let rowIndex = 0; rowIndex < Math.min(v.details.length, rowCount); rowIndex++) {
1274
+ if (count >= cap) return
1275
+ const detail = v.details[rowIndex]
1276
+ if (Array.isArray(detail) && isTableDetailReachable(v, rowIndex)) walk(detail)
1277
+ }
1379
1278
  }
1380
1279
  }
1381
1280
  }
@@ -1432,6 +1331,11 @@ function visitDeclaredGenuiNodes(
1432
1331
  for (let row = 0; row < v.items.length; row++) {
1433
1332
  walkNode(v.items[row], `${at}.items[${row}]`)
1434
1333
  }
1334
+ } else if (v.type === 'table' && Array.isArray(v.details)) {
1335
+ const rowCount = tableRowsForDetails(v).length
1336
+ for (let row = 0; row < Math.min(v.details.length, rowCount); row++) {
1337
+ if (Array.isArray(v.details[row]) && isTableDetailReachable(v, row)) walk(v.details[row], `${at}.details[${row}]`)
1338
+ }
1435
1339
  }
1436
1340
  }
1437
1341
  function walkItemsOf(holder: unknown, path: string): void {
@@ -1524,6 +1428,7 @@ export function validateCanonicalGenuiSpec(value: unknown): GenuiValidation {
1524
1428
  }
1525
1429
  }
1526
1430
  walk(v.items, 0, 'items')
1431
+ if (errors.length === 0) errors.push(...analyzeSubmissionRegistry(v as unknown as GenuiSpec).diagnostics)
1527
1432
  const uniqueErrors = [...new Set(errors)]
1528
1433
  return { ok: uniqueErrors.length === 0, errors: uniqueErrors }
1529
1434
  }
@@ -1590,6 +1495,9 @@ export function processGenuiSpec(value: unknown): GenuiProcessResult {
1590
1495
  // unknown type. The processing pipeline is renderer-aware by contract:
1591
1496
  // custom nodes stay opaque and must not fail native schema validation.
1592
1497
  const errors = validation.errors.filter(error => !error.includes(': unknown type '))
1498
+ if (repaired !== null && errors.length === 0) {
1499
+ errors.push(...analyzeSubmissionRegistry(repaired).diagnostics)
1500
+ }
1593
1501
  if (declaredNativeCount > renderedNativeCount) {
1594
1502
  errors.push(`repair dropped ${declaredNativeCount - renderedNativeCount} declared native node(s): declared ${declaredNativeCount}, rendered ${renderedNativeCount}`)
1595
1503
  }
@@ -1598,7 +1506,7 @@ export function processGenuiSpec(value: unknown): GenuiProcessResult {
1598
1506
  normalized: normalized.value,
1599
1507
  repaired,
1600
1508
  spec: repaired,
1601
- errors,
1509
+ errors: [...new Set(errors)],
1602
1510
  warnings: [...normalized.warnings, ...diagnoseUnknownGenuiFields(normalized.value)],
1603
1511
  // Compatibility fields retain their historical meanings: declaredCount
1604
1512
  // is native declarations, while renderedCount is the total rendered tree.
@@ -1625,30 +1533,37 @@ export function isRenderableProcess(processed: GenuiProcessResult): boolean {
1625
1533
 
1626
1534
  /* ---------------- partial fence rendering (issue #186) ---------------- */
1627
1535
 
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+\]))*)/
1536
+ /** Longest node or aligned table-detail slot path a validation error points at. */
1537
+ const DECLARED_NODE_PATH_RE = /^(items\[\d+\](?:\.(?:items\[\d+\]|tabs\[\d+\]\.items\[\d+\]|details\[\d+\](?:\[\d+\])?))*)/
1630
1538
 
1631
1539
  function errorNodePath(error: string): string | null {
1632
1540
  const match = DECLARED_NODE_PATH_RE.exec(error)
1633
1541
  return match === null ? null : match[1] ?? null
1634
1542
  }
1635
1543
 
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
1544
+ interface NodeSlot {
1545
+ array: unknown[]
1546
+ index: number
1547
+ /** Detail row slots align with table rows; pruning must not shift them. */
1548
+ preserveIndex: boolean
1549
+ }
1550
+
1551
+ /** Where the node or table-detail slot at a validation path lives. */
1552
+ function nodeSlotAt(root: Record<string, unknown>, path: string): NodeSlot | undefined {
1553
+ const steps = [...path.matchAll(/(?:^|\.)(items|tabs|details)\[(\d+)\]|\[(\d+)\]/g)]
1554
+ if (steps.length === 0 || steps[steps.length - 1]![1] === 'tabs') return undefined
1640
1555
  let current: unknown = root
1641
- for (let i = 0; i < steps.length - 1; i++) {
1556
+ for (let i = 0; i < steps.length; i++) {
1642
1557
  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
1558
+ // A detail child has a second index without a property: details[row][node].
1559
+ const list = step[1] === undefined ? current : obj(current)?.[step[1]]
1560
+ const index = Number(step[2] ?? step[3])
1561
+ if (!Array.isArray(list) || index >= list.length) return undefined
1562
+ if (i === steps.length - 1) return { array: list, index, preserveIndex: step[1] === 'details' }
1563
+ current = list[index]
1646
1564
  if (current === undefined) return undefined
1647
1565
  }
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]) }
1566
+ return undefined
1652
1567
  }
1653
1568
 
1654
1569
  /** Deep-clone a JSON value for pruning; null when it cannot round-trip. */
@@ -1676,18 +1591,21 @@ export function partialRepairGenuiSpec(processed: GenuiProcessResult): GenuiSpec
1676
1591
  if (isRenderableProcess(processed)) return processed.spec
1677
1592
  if (processed.spec === null) return null
1678
1593
  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
1594
+ if (root === undefined) return null
1683
1595
  const paths = new Set<string>()
1596
+ const overhangPaths = new Set<string>()
1684
1597
  for (const error of processed.errors) {
1685
1598
  if (error.startsWith('spec exceeds ')) continue
1686
1599
  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)
1600
+ if (nodePath === null) continue
1601
+ if (error === `${nodePath}.details must not contain more entries than rows`) {
1602
+ overhangPaths.add(nodePath)
1603
+ } else paths.add(nodePath)
1604
+ }
1605
+ if (paths.size === 0 && overhangPaths.size === 0) return null
1606
+ // Validation wraps a bare component first. Prune the same shape so its
1607
+ // detail paths resolve even when the table is the only declared node.
1608
+ const pruned = cloneJsonValue(isComponentRoot(root) ? wrapSingleComponentRoot(root) : root)
1691
1609
  if (pruned === null) return null
1692
1610
  // Resolve every slot BEFORE the first splice: each drop shifts the later
1693
1611
  // siblings of the same array, so a resolve-after-splice (deepest-first or
@@ -1697,9 +1615,24 @@ export function partialRepairGenuiSpec(processed: GenuiProcessResult): GenuiSpec
1697
1615
  // highest-index first keeps the remaining indexes valid (issue #190).
1698
1616
  const slots = [...paths]
1699
1617
  .map((path) => nodeSlotAt(pruned, path))
1700
- .filter((slot): slot is { array: unknown[]; index: number } => slot !== undefined)
1618
+ .filter((slot): slot is NodeSlot => slot !== undefined)
1619
+ const overhangSlots = [...overhangPaths]
1620
+ .map((path) => nodeSlotAt(pruned, path))
1621
+ .filter((slot): slot is NodeSlot => slot !== undefined)
1622
+ // Extra details are unreachable. Mirror table repair's existing truncation
1623
+ // only for this exact diagnostic, then validate the whole candidate again.
1624
+ for (const { array, index } of overhangSlots) {
1625
+ const table = obj(array[index])
1626
+ if (table?.type === 'table' && Array.isArray(table.details)) {
1627
+ table.details.length = Math.min(table.details.length, tableRowsForDetails(table).length)
1628
+ }
1629
+ }
1701
1630
  const byArray = new Map<unknown[], number[]>()
1702
- for (const { array, index } of slots) {
1631
+ for (const { array, index, preserveIndex } of slots) {
1632
+ if (preserveIndex) {
1633
+ array[index] = null
1634
+ continue
1635
+ }
1703
1636
  const indexes = byArray.get(array)
1704
1637
  if (indexes === undefined) byArray.set(array, [index])
1705
1638
  else indexes.push(index)
@@ -1864,9 +1797,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
1864
1797
  break
1865
1798
  case 'submit':
1866
1799
  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.
1800
+ // 本地判卷可以不设置 action;不满足判卷条件时,渲染器会禁用无 action 的按钮。
1870
1801
  break
1871
1802
  case 'badge':
1872
1803
  if (typeof v.label !== 'string' && typeof v.text !== 'string' && typeof v.value !== 'string') {
@@ -1920,6 +1851,20 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
1920
1851
  if (v.details !== undefined && !Array.isArray(v.details)) {
1921
1852
  errors.push(`${at}.details must be an array aligned with rows`)
1922
1853
  }
1854
+ if (Array.isArray(v.details)) {
1855
+ const table = { columns: v.columns, rows: v.rows, types: v.types }
1856
+ const rowCount = tableRowsForDetails(table).length
1857
+ if (v.details.length > rowCount) errors.push(`${at}.details must not contain more entries than rows`)
1858
+ for (let i = 0; i < Math.min(v.details.length, rowCount); i++) {
1859
+ const detail = v.details[i]
1860
+ if (detail === null || !isTableDetailReachable(table, i)) continue
1861
+ if (!Array.isArray(detail)) {
1862
+ errors.push(`${at}.details[${i}] must be an array or null`)
1863
+ continue
1864
+ }
1865
+ walk(detail, depth + 1, `${at}.details[${i}]`)
1866
+ }
1867
+ }
1923
1868
  validateTableRows(v.rows, `${at}.rows`, errors)
1924
1869
  break
1925
1870
  case 'chart':