@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
@@ -288,6 +288,10 @@ export const EN = {
288
288
  // ------------------------------------------------- component chrome
289
289
  'block.copyMarkdown': 'Copy Markdown',
290
290
  'block.copyCsv': 'Copy CSV',
291
+ 'block.table.expandDetails': 'Expand details for {label}',
292
+ 'block.table.collapseDetails': 'Collapse details for {label}',
293
+ 'block.table.expandGroup': 'Expand group {label}',
294
+ 'block.table.collapseGroup': 'Collapse group {label}',
291
295
  'block.total': 'Total',
292
296
  'block.filteredRows': '{visible} / {total} rows after filtering',
293
297
  'block.filterMatch': '{shown} / {total} matched',
@@ -303,7 +307,7 @@ export const EN = {
303
307
  'block.svgPreview': 'Preview',
304
308
  'block.svgSource': 'Source',
305
309
  'block.svgError': 'SVG could not be displayed — showing the source instead',
306
- 'block.mermaidError': 'Invalid diagram syntax — showing the source instead',
310
+ 'block.mermaidError': 'Diagram rendering failed — showing the source instead',
307
311
  'block.mermaidLoading': 'Rendering…',
308
312
  'block.scene3dLoading': 'Loading 3D scene…',
309
313
  'block.scene3dError': '3D rendering failed',
@@ -283,6 +283,10 @@ export const ZH: Record<keyof typeof EN, string> = {
283
283
  // ------------------------------------------------- component chrome
284
284
  'block.copyMarkdown': '复制 Markdown',
285
285
  'block.copyCsv': '复制 CSV',
286
+ 'block.table.expandDetails': '展开 {label} 的明细',
287
+ 'block.table.collapseDetails': '收起 {label} 的明细',
288
+ 'block.table.expandGroup': '展开分组 {label}',
289
+ 'block.table.collapseGroup': '收起分组 {label}',
286
290
  'block.total': '合计',
287
291
  'block.filteredRows': '筛选后 {visible} / {total} 行',
288
292
  'block.filterMatch': '匹配 {shown} / {total} 项',
@@ -298,7 +302,7 @@ export const ZH: Record<keyof typeof EN, string> = {
298
302
  'block.svgPreview': '预览',
299
303
  'block.svgSource': '源码',
300
304
  'block.svgError': 'SVG 无法显示,已降级显示源码',
301
- 'block.mermaidError': '图语法有误,已降级显示源码',
305
+ 'block.mermaidError': '图表渲染失败,已显示源码',
302
306
  'block.mermaidLoading': '渲染中…',
303
307
  'block.scene3dLoading': '加载 3D 场景…',
304
308
  'block.scene3dError': '3D 渲染失败',
@@ -26,9 +26,11 @@ import type {} from '@deepseek-ai/dsh-api-session-controller/client'
26
26
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
27
27
  import type { SessionId } from '@deepseek-ai/dsh-session/types'
28
28
  import type { IConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
29
- import type { Key, ReactNode } from 'react'
29
+ import { createElement, type Key, type ReactNode } from 'react'
30
+ import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
30
31
  import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
31
32
  import { installDomFenceRenderer } from './dom-fence.tsx'
33
+ import { createFileLinkAvailability, withFileLinkContext, type FileLinkCordisContext } from './file-link-context.tsx'
32
34
  import { renderGenuiFence, type GenuiFenceContext } from './fence-render.tsx'
33
35
  import { renderSvgFence } from './svg-fence.tsx'
34
36
  import { createPanelSlashSource } from './panel-command.ts'
@@ -151,11 +153,18 @@ export function apply(ctx: Context): () => void {
151
153
  const localeDispose = bridgeHostLocale(ctx)
152
154
  const registerFn = (primitives as unknown as HostFenceExt).registerFenceRenderer
153
155
  const useRegistry = typeof registerFn === 'function' && !forcedDomChannel()
156
+ const fileLinkAvailability = createFileLinkAvailability(ctx as unknown as FileLinkCordisContext)
154
157
  const channel = useRegistry ? 'registry' : 'dom'
155
158
  console.info(`[genui] client active; fence-channel=${channel}`)
156
159
  const disposers: Array<() => void> = useRegistry
157
- ? [localeDispose, registerFn!('dsh-ui', renderGenuiFence), registerFn!('svg', renderSvgFence)]
158
- : [localeDispose, installDomFenceRenderer(ctx, (sessionId, action, payload) => sendInlineGenuiAction(ctx, sessionId, action, payload))]
160
+ ? [localeDispose, registerFn!('dsh-ui', (raw, key, context) => withFileLinkContext(
161
+ ctx as unknown as FileLinkCordisContext,
162
+ fileLinkAvailability,
163
+ context?.sessionId,
164
+ context?.source === undefined,
165
+ renderGenuiFence(raw, key, context),
166
+ )), registerFn!('svg', renderSvgFence)]
167
+ : [localeDispose, installDomFenceRenderer(ctx, (sessionId, action, payload) => sendInlineGenuiAction(ctx, sessionId, action, payload), fileLinkAvailability)]
159
168
  // Idle prefetch of the lazy engine assets: the browser downloads them at
160
169
  // LOW priority whenever the page is idle, so the first mermaid/3D node in
161
170
  // a session usually hits a warm cache instead of paying the fetch on first
@@ -170,7 +179,13 @@ export function apply(ctx: Context): () => void {
170
179
  disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
171
180
  name: 'tool.call.toolview',
172
181
  key: 'render_ui',
173
- }, GenuiToolView)))
182
+ }, (props: ToolCallViewProps) => withFileLinkContext(
183
+ ctx as unknown as FileLinkCordisContext,
184
+ fileLinkAvailability,
185
+ props.sessionId,
186
+ false,
187
+ createElement(GenuiToolView, props),
188
+ ))))
174
189
  // Session panel dock: a session-scoped, always-present seat above the
175
190
  // composer (TodoDock posture). Renders the session's latest render_ui
176
191
  // spec in place; absent spec = no panel.
@@ -178,7 +193,10 @@ export function apply(ctx: Context): () => void {
178
193
  name: 'conversation.input.dock',
179
194
  id: 'genui-panel',
180
195
  order: 50,
181
- inject: (sessionId: SessionId): GenuiPanelInjected => panelActionSend(ctx, sessionId),
196
+ inject: (sessionId: SessionId): GenuiPanelInjected => ({
197
+ ...panelActionSend(ctx, sessionId),
198
+ fileLinkContext: { ctx: ctx as unknown as FileLinkCordisContext, availability: fileLinkAvailability },
199
+ }),
182
200
  }, GenuiPanel)))
183
201
  // /panel slash command: a deterministic, client-side entry point that
184
202
  // opens the panel dock (publishes the default spec + expand request),
@@ -2,7 +2,8 @@
2
2
  import { createElement, useLayoutEffect, useRef, type ReactNode } from 'react'
3
3
  import katex from 'katex'
4
4
  import css from './GenuiBlock.module.css'
5
- import { safeHref } from './genui-runtime/value-utils.ts'
5
+ import { classifyLinkTarget } from './file-link.ts'
6
+ import { FileLink } from './file-link-context.tsx'
6
7
 
7
8
  /** KaTeX owns this span's children; React owns the span and its lifecycle.
8
9
  * The host's ui-primitives already supplies KaTeX CSS/fonts, including embeds. */
@@ -20,9 +21,13 @@ function InlineMath({ source, display }: { source: string; display: boolean }) {
20
21
 
21
22
  // Code is literal. TeX tokens are opaque to emphasis/link parsing; the other
22
23
  // 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.
24
+ // A real newline in the string stays a REAL newline in the DOM — not a `<br>`.
25
+ // A `<br>` paints a visual break but contributes nothing to `textContent`, so
26
+ // selecting and copying a multi-line cell yielded one run-on line: a pasted
27
+ // `python - <<'PY' … PY` heredoc lost its line structure and could no longer
28
+ // run. Real newlines survive both the paint and the copy path; containers that
29
+ // can carry them declare `white-space: pre-line` (prose) or `pre-wrap` (code).
30
+ // No HTML parsing happens here, only the model's `\n`.
26
31
  const INLINE = /(?<!`)`[^`\n]+`(?!`)|\\\\|\\\$|(?<![\\$])\$\$(?:\\.|[^\\])*?\$\$|\\\[(?:\\(?!\])[^]|[^\\])*?\\\]|\\\((?:\\(?!\))[^]|[^\\])*?\\\)|(?<![\\$])\$(?!\s|\$)(?:\\.|[^$\\\n])+(?<!\s)\$(?!\d|\$)|\*\*[\s\S]+?\*\*|==[\s\S]+?==|\[[^\]\n]+\]\([^)\s]+\)|\r?\n/g
27
32
  const FENCE_MARKER = /`{3,}|~{3,}/g
28
33
 
@@ -31,7 +36,7 @@ export function hasInlineMarkup(text: string): boolean {
31
36
  }
32
37
 
33
38
  /** Render safe phrasing content, usable in headings, buttons and labels too. */
34
- export function renderInline(text: string, allowLinks = true, depth = 0): ReactNode {
39
+ export function renderInline(text: string, allowLinks: boolean | 'file' = true, depth = 0): ReactNode {
35
40
  if (typeof text !== 'string' || text === '' || !hasInlineMarkup(text) || depth >= 8) return text
36
41
  const segments: ReactNode[] = []
37
42
  let fenceEnd = 0
@@ -65,7 +70,10 @@ export function renderInline(text: string, allowLinks = true, depth = 0): ReactN
65
70
  const token = match[0]
66
71
  if (index > last) out.push(text.slice(last, index))
67
72
  if (token === '\n' || token === '\r\n') {
68
- out.push(createElement('br', { key: key++ }))
73
+ // Keep the newline character itself: it is what `textContent`,
74
+ // `Selection.toString()` and the clipboard carry. `pre-line`/`pre-wrap`
75
+ // on the owning container turns it into a visible line break.
76
+ out.push('\n')
69
77
  } else if (token.startsWith('`')) {
70
78
  out.push(createElement('code', { key: key++, className: css.inlineCode }, token.slice(1, -1)))
71
79
  } else if (token === '\\$' || token === '\\\\') {
@@ -86,10 +94,20 @@ export function renderInline(text: string, allowLinks = true, depth = 0): ReactN
86
94
  if (parts === null) {
87
95
  out.push(token)
88
96
  } else {
89
- const href = safeHref(parts[2])
90
- out.push(href === undefined || !allowLinks ? renderInline(parts[1]!, false, depth + 1) : createElement('a', {
91
- key: key++, className: css.inlineLink, href, target: '_blank', rel: 'noreferrer noopener',
92
- }, renderInline(parts[1]!, false, depth + 1)))
97
+ const target = classifyLinkTarget(parts[2])
98
+ const label = renderInline(parts[1]!, false, depth + 1)
99
+ if (target.type === 'external' && allowLinks === true) {
100
+ out.push(createElement('a', {
101
+ key: key++, className: css.inlineLink, href: target.href, target: '_blank', rel: 'noreferrer noopener',
102
+ }, label))
103
+ } else if (target.type === 'file' && allowLinks !== false) {
104
+ out.push(createElement(FileLink, {
105
+ key: key++, path: target.file.path, label: parts[1]!,
106
+ ...(target.file.line === undefined ? {} : { line: target.file.line }),
107
+ }, label))
108
+ } else {
109
+ out.push(label)
110
+ }
93
111
  }
94
112
  }
95
113
  last = index + token.length
@@ -0,0 +1,39 @@
1
+ /** Resolve host CSS colors before passing them to Mermaid's limited parser. */
2
+ export function readMermaidThemeColor(name: string, fallback: string): string {
3
+ if (typeof document === 'undefined' || document.body === null) return fallback
4
+ const value = getComputedStyle(document.body).getPropertyValue(name).trim()
5
+ if (value === '') return fallback
6
+
7
+ // Computed custom properties still contain calc(), unlike computed color.
8
+ // A real color property also resolves currentColor and inherited variables.
9
+ const probe = document.createElement('span')
10
+ probe.style.setProperty('color', value, 'important')
11
+ if (probe.style.color === '') return fallback
12
+ probe.style.setProperty('display', 'none', 'important')
13
+ document.body.appendChild(probe)
14
+ let color: string
15
+ try {
16
+ color = getComputedStyle(probe).color
17
+ } finally {
18
+ probe.remove()
19
+ }
20
+ if (/^rgba?\([\d.,\s]+\)$/i.test(color)) return color
21
+
22
+ // Modern colors such as oklch() and color-mix() may remain in those formats
23
+ // after style resolution. Canvas converts them to the sRGB Mermaid accepts.
24
+ const canvas = document.createElement('canvas')
25
+ canvas.width = canvas.height = 1
26
+ const context = canvas.getContext('2d')
27
+ if (context === null) return fallback
28
+ context.fillStyle = '#010203'
29
+ const initialFillStyle = context.fillStyle
30
+ context.fillStyle = color
31
+ if (context.fillStyle === initialFillStyle) return fallback
32
+ try {
33
+ context.fillRect(0, 0, 1, 1)
34
+ const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data
35
+ return `rgba(${r}, ${g}, ${b}, ${a! / 255})`
36
+ } catch {
37
+ return fallback
38
+ }
39
+ }
@@ -16,6 +16,7 @@
16
16
  * @module @changfenhuang/dsh-genui/client/mermaid-core
17
17
  */
18
18
  import { assertSafeSvg, ensureFlowchartKind, repairMermaidSource } from './mermaid-safe.ts'
19
+ import { readMermaidThemeColor } from './mermaid-colors.ts'
19
20
 
20
21
  let mermaidPromise: Promise<typeof import('mermaid')> | null = null
21
22
 
@@ -30,10 +31,13 @@ function loadMermaid(): Promise<typeof import('mermaid')> {
30
31
  // strokes and their own font, which sat next to our components as a
31
32
  // visibly foreign object. `base` + themeVariables is the supported way to
32
33
  // drive every colour (the dark/light branch is gone with it).
34
+ const colors = new Map<string, string>()
33
35
  const token = (name: string, fallback: string): string => {
34
- if (typeof document === 'undefined') return fallback
35
- const value = getComputedStyle(document.body).getPropertyValue(name).trim()
36
- return value === '' ? fallback : value
36
+ const cached = colors.get(name)
37
+ if (cached !== undefined) return cached
38
+ const color = readMermaidThemeColor(name, fallback)
39
+ colors.set(name, color)
40
+ return color
37
41
  }
38
42
  api.initialize({
39
43
  startOnLoad: false,
@@ -26,6 +26,7 @@
26
26
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
27
27
  import type {} from '@deepseek-ai/dsh-client-ui-session/client'
28
28
  import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
29
+ import { withFileLinkContext, type FileLinkAvailability, type FileLinkCordisContext } from './file-link-context.tsx'
29
30
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
30
31
  import { ExportableGenuiBlock } from './artifact/ExportableGenuiBlock.tsx'
31
32
  import { ErrorBoundary } from './ErrorBoundary.tsx'
@@ -50,6 +51,7 @@ import { recordPanel, recordTemplateUse } from './achievement-store.ts'
50
51
  export interface GenuiPanelInjected {
51
52
  sessionId: string
52
53
  sendGenuiAction: GenuiActionHandler
54
+ fileLinkContext?: { ctx: FileLinkCordisContext; availability: FileLinkAvailability }
53
55
  /** Template center "try it": insert the template instruction into the
54
56
  * current composer draft (standard conversation.input.for channel). */
55
57
  insertTemplate: (text: string) => void
@@ -63,7 +65,7 @@ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPan
63
65
  * default so the dock never steals the message flow's scroll room; the
64
66
  * header always shows the current panel title.
65
67
  */
66
- export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: GenuiPanelProps) {
68
+ export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate, fileLinkContext }: GenuiPanelProps) {
67
69
  const t = useT()
68
70
  const spec = useSyncExternalStore(subscribePanel, () => getPanelSpec(sessionId))
69
71
  const expandToken = useSyncExternalStore(subscribePanelExpand, () => getPanelExpandToken(sessionId))
@@ -271,10 +273,21 @@ export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: Genui
271
273
  />
272
274
  ) : spec !== null ? (
273
275
  <GenuiActionContext.Provider value={sendGenuiAction}>
274
- <ErrorBoundary label={t('panel.boundary')}>
275
- {/* content-fingerprinted: same panel spec re-published restores its state */}
276
- <ExportableGenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
277
- </ErrorBoundary>
276
+ {fileLinkContext === undefined
277
+ ? <ErrorBoundary label={t('panel.boundary')}>
278
+ {/* content-fingerprinted: same panel spec re-published restores its state */}
279
+ <ExportableGenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
280
+ </ErrorBoundary>
281
+ : withFileLinkContext(
282
+ fileLinkContext.ctx,
283
+ fileLinkContext.availability,
284
+ sessionId,
285
+ false,
286
+ <ErrorBoundary label={t('panel.boundary')}>
287
+ {/* content-fingerprinted: same panel spec re-published restores its state */}
288
+ <ExportableGenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
289
+ </ErrorBoundary>,
290
+ )}
278
291
  </GenuiActionContext.Provider>
279
292
  ) : null}
280
293
  </div>
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ import { GENUI_LIMITS } from './genui-runtime/index.ts'
2
+
3
+ /** 保存 repair 所需的原始 table 字段。 */
4
+ export interface TableDetailSource {
5
+ columns?: unknown
6
+ rows?: unknown
7
+ data?: unknown
8
+ types?: unknown
9
+ }
10
+
11
+ /** 保存 repair 后的 table 列和行。 */
12
+ export interface PreparedTableRows {
13
+ columns: string[]
14
+ rows: Array<Array<string | number>>
15
+ }
16
+
17
+ /** 使用 table repair 相同的转换规则处理列和行。 */
18
+ export function prepareTableRows(table: TableDetailSource): PreparedTableRows | null {
19
+ const declaredColumns = table.columns
20
+ const columnsSpecified = Array.isArray(declaredColumns) && declaredColumns.length > 0
21
+ const rawColumns = columnsSpecified && Array.isArray(declaredColumns)
22
+ && typeof declaredColumns[0] === 'object' && declaredColumns[0] !== null
23
+ ? declaredColumns.map(columnHeaderText)
24
+ : declaredColumns
25
+ let columns = repairColumnValues(rawColumns)
26
+ let rawRows = table.rows !== undefined ? table.rows : table.data
27
+
28
+ if (Array.isArray(rawRows) && rawRows.length > 0
29
+ && typeof rawRows[0] === 'object' && rawRows[0] !== null && !Array.isArray(rawRows[0])) {
30
+ const keys = Array.isArray(table.columns) && table.columns.length > 0
31
+ && typeof table.columns[0] === 'object' && table.columns[0] !== null
32
+ ? table.columns.map(columnKeyOf).filter((key): key is string => key !== undefined)
33
+ : Object.keys(rawRows[0] as Record<string, unknown>)
34
+ rawRows = rawRows.map(row => keys.map(key => cellText((row as Record<string, unknown>)[key])))
35
+ }
36
+
37
+ let derived: PreparedTableRows | null = null
38
+ if (!columnsSpecified && Array.isArray(rawRows) && rawRows.length > 0 && Array.isArray(rawRows[0])) {
39
+ const grid = repairRows(rawRows)
40
+ const candidate = grid.length === 0 ? null : deriveTableColumns(grid)
41
+ if (candidate !== null && repairRows(candidate.rows).length === candidate.rows.length) {
42
+ derived = candidate
43
+ columns = candidate.columns.map(column => column.slice(0, 128))
44
+ }
45
+ }
46
+
47
+ if (columns.length === 0 && !Array.isArray(table.columns)) return null
48
+ const rows = repairRows(derived === null ? rawRows : derived.rows)
49
+ if (!Array.isArray(rawRows)) return null
50
+ return { columns: columns.slice(0, GENUI_LIMITS.maxTableCols), rows }
51
+ }
52
+
53
+ /** 返回 repair 后可显示详情的 table 行。 */
54
+ export function tableRowsForDetails<Row = unknown>(table: TableDetailSource): Row[] {
55
+ return (prepareTableRows(table)?.rows ?? []) as Row[]
56
+ }
57
+
58
+ /** 按照 table renderer 的规则识别分组标题行。 */
59
+ export function isTableGroupHeaderRow(row: unknown, types: unknown): boolean {
60
+ if (!Array.isArray(row) || !Array.isArray(types) || types[0] !== 'group') return false
61
+ return String(row[0] ?? '').trim() !== ''
62
+ && row.slice(1).every(cell => String(cell ?? '').trim() === '')
63
+ }
64
+
65
+ /** 判断详情索引对应的行是否可展开并显示。 */
66
+ export function isTableDetailReachable(table: TableDetailSource, rowIndex: number): boolean {
67
+ const rows = tableRowsForDetails(table)
68
+ return rowIndex >= 0
69
+ && rowIndex < rows.length
70
+ && !isTableGroupHeaderRow(rows[rowIndex], table.types)
71
+ }
72
+
73
+ /** 按照 guard 接受的 object alias 规则修复 table 列。 */
74
+ function repairColumnValues(value: unknown): string[] {
75
+ if (!Array.isArray(value)) return []
76
+ const columns: string[] = []
77
+ for (const item of value) {
78
+ if (columns.length >= GENUI_LIMITS.maxTableCols) break
79
+ if (typeof item === 'string') columns.push(item.slice(0, 128))
80
+ else if (item !== null && typeof item === 'object') {
81
+ const column = item as Record<string, unknown>
82
+ const label = typeof column.label === 'string' ? column.label
83
+ : typeof column.value === 'string' ? column.value
84
+ : typeof column.title === 'string' ? column.title
85
+ : JSON.stringify(item)
86
+ columns.push(label.slice(0, 128))
87
+ }
88
+ }
89
+ return columns
90
+ }
91
+
92
+ /** 按照 table repair 的单元格规则裁剪并清理行。 */
93
+ function repairRows(value: unknown): Array<Array<string | number>> {
94
+ if (!Array.isArray(value)) return []
95
+ const rows: Array<Array<string | number>> = []
96
+ for (const row of value) {
97
+ if (rows.length >= GENUI_LIMITS.maxTableRows) break
98
+ if (!Array.isArray(row)) continue
99
+ const cells: Array<string | number> = []
100
+ for (const cell of row) {
101
+ if (cells.length >= GENUI_LIMITS.maxTableCols) break
102
+ if (typeof cell === 'string') cells.push(cell.slice(0, 256))
103
+ else if (typeof cell === 'number' && Number.isFinite(cell)) cells.push(cell)
104
+ }
105
+ if (cells.length > 0) rows.push(cells)
106
+ }
107
+ return rows
108
+ }
109
+
110
+ /** 从有效的首行表头生成 table 列。 */
111
+ function deriveTableColumns(rows: Array<Array<string | number>>): PreparedTableRows | null {
112
+ const header = rows[0]
113
+ if (header === undefined || header.length === 0) return null
114
+ const body = rows.slice(1)
115
+ if (body.length === 0) {
116
+ const columns = header.map(cell => String(cell).trim())
117
+ return columns.every(column => column !== '') ? { columns, rows: [] } : null
118
+ }
119
+ if (body.every(row => row.length === header.length)) {
120
+ return { columns: header.map(cell => String(cell).trim()), rows: body }
121
+ }
122
+ return { columns: Array.from({ length: header.length }, (_unused, index) => `列${index + 1}`), rows }
123
+ }
124
+
125
+ /** 读取 object row 映射到列时使用的 key。 */
126
+ function columnKeyOf(value: unknown): string | undefined {
127
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
128
+ const column = value as Record<string, unknown>
129
+ for (const key of ['key', 'dataIndex', 'title', 'label'] as const) {
130
+ if (typeof column[key] === 'string' && column[key] !== '') return column[key]
131
+ }
132
+ return undefined
133
+ }
134
+
135
+ /** 将 object row 中的值转换为 table 单元格,并保留列位置。 */
136
+ function cellText(value: unknown): string | number {
137
+ if (typeof value === 'string') return value
138
+ if (typeof value === 'number' && Number.isFinite(value)) return value
139
+ if (value === null || value === undefined) return ''
140
+ return JSON.stringify(value)
141
+ }
142
+
143
+ /** 读取 object column 显示的标题。 */
144
+ function columnHeaderText(value: unknown): string {
145
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return String(value)
146
+ const column = value as Record<string, unknown>
147
+ for (const key of ['title', 'label', 'key', 'dataIndex'] as const) {
148
+ if (typeof column[key] === 'string' && column[key] !== '') return column[key]
149
+ }
150
+ return JSON.stringify(value)
151
+ }