@changfenhuang/dsh-genui 0.11.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +21 -6
- package/README.zh-CN.md +21 -6
- package/SKILL.md +18 -6
- package/lib/assets/mermaid.js +325 -325
- package/lib/assets/standalone-runtime.js +10 -9
- package/lib/client.js +31 -33
- package/lib/index.js +1060 -371
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/dom-fence.d.ts +2 -1
- package/lib/types/client/file-address.d.ts +4 -0
- package/lib/types/client/file-link-context.d.ts +41 -0
- package/lib/types/client/file-link.d.ts +17 -0
- package/lib/types/client/i18n/en.d.ts +5 -1
- package/lib/types/client/inline.d.ts +1 -1
- package/lib/types/client/mermaid-colors.d.ts +2 -0
- package/lib/types/client/panel.d.ts +6 -1
- package/lib/types/client/spec.d.ts +8 -11
- package/lib/types/client/submission-registry.d.ts +51 -0
- package/lib/types/client/table-details.d.ts +20 -0
- package/lib/types/client/walk-spec.d.ts +3 -0
- package/lib/types/plugin/fence-feedback.d.ts +55 -12
- package/lib/types/plugin/index.d.ts +7 -3
- package/lib/types/shared/fence-repair.d.ts +19 -7
- package/package.json +1 -12
- package/src/client/GenuiBlock.module.css +100 -4
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/artifact/ArtifactExport.module.css +34 -3
- package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
- package/src/client/blocks/charts.tsx +63 -14
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +11 -8
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +190 -50
- package/src/client/file-address.ts +37 -0
- package/src/client/file-link-context.tsx +114 -0
- package/src/client/file-link.ts +46 -0
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +98 -153
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.tsx +23 -5
- package/src/client/inline.ts +28 -10
- package/src/client/mermaid-colors.ts +39 -0
- package/src/client/mermaid-core.ts +7 -3
- package/src/client/panel.tsx +18 -5
- package/src/client/spec.ts +8 -11
- package/src/client/submission-registry.ts +150 -0
- package/src/client/table-details.ts +151 -0
- package/src/client/walk-spec.ts +43 -0
- package/src/plugin/fence-feedback.ts +384 -53
- package/src/plugin/index.ts +20 -9
- package/src/plugin/tool.ts +8 -2
- package/src/shared/fence-repair.ts +345 -128
- package/tsdown.config.ts +3 -7
- package/lib/invariant.js +0 -19
- package/lib/types/plugin/invariant.d.ts +0 -15
- package/src/plugin/invariant.ts +0 -30
package/src/client/i18n/en.ts
CHANGED
|
@@ -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': '
|
|
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',
|
package/src/client/i18n/zh.ts
CHANGED
|
@@ -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 渲染失败',
|
package/src/client/index.tsx
CHANGED
|
@@ -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
|
|
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',
|
|
158
|
-
|
|
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
|
-
},
|
|
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 =>
|
|
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),
|
package/src/client/inline.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
package/src/client/panel.tsx
CHANGED
|
@@ -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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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>
|
package/src/client/spec.ts
CHANGED
|
@@ -471,17 +471,13 @@ export interface GenuiRadio {
|
|
|
471
471
|
explanation?: string
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
/**
|
|
475
|
-
* this block. LOCAL-FIRST grading still applies to radio-only question scopes;
|
|
476
|
-
* aggregation scopes can emit strings for radio groups and string arrays for
|
|
477
|
-
* checkbox groups in the same `answers` object. */
|
|
474
|
+
/** 提交节点收集当前 block 的答案和字段值;纯 radio 范围可在本地判卷。 */
|
|
478
475
|
export interface GenuiSubmit {
|
|
479
476
|
type: 'submit'
|
|
480
477
|
label: string
|
|
481
478
|
/**
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
* an action and emit `{type:'submit', answers, total, answered}`.
|
|
479
|
+
* 可选 action 名称。纯 radio 范围满足本地判卷条件时无需 action;其他提交
|
|
480
|
+
* 通过 action 发送 `answers`、可选的 `fields`、`total` 和 `answered`。
|
|
485
481
|
*/
|
|
486
482
|
action?: string
|
|
487
483
|
/**
|
|
@@ -491,10 +487,11 @@ export interface GenuiSubmit {
|
|
|
491
487
|
*/
|
|
492
488
|
resetAction?: string
|
|
493
489
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
490
|
+
* `groups` 引用当前 block 中的 submission member key,来源为 radio.group、
|
|
491
|
+
* checkbox.group 以及 input/textarea/select/slider.id。radio 需已选择,
|
|
492
|
+
* checkbox 需至少选择一项,普通 field 需在 trim 后非空。`groups` 控制提交
|
|
493
|
+
* 所需成员与完成进度;payload 仍收集当前 block 中已填写的表单状态。
|
|
494
|
+
* 未设置时,只要存在至少一个已完成成员即可提交。
|
|
498
495
|
*/
|
|
499
496
|
groups?: string[]
|
|
500
497
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { GENUI_LIMITS } from './genui-runtime/index.ts'
|
|
2
|
+
import type { GenuiSpec } from './spec.ts'
|
|
3
|
+
import { walkGenuiNodes } from './walk-spec.ts'
|
|
4
|
+
|
|
5
|
+
export type SubmissionMember = RadioSubmissionMember | CheckboxSubmissionMember | FieldSubmissionMember
|
|
6
|
+
|
|
7
|
+
export interface RadioSubmissionMember {
|
|
8
|
+
kind: 'radio'
|
|
9
|
+
key: string
|
|
10
|
+
label: string
|
|
11
|
+
options: string[]
|
|
12
|
+
answer?: number | string
|
|
13
|
+
explanation?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CheckboxSubmissionMember {
|
|
17
|
+
kind: 'checkbox'
|
|
18
|
+
key: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FieldSubmissionMember {
|
|
22
|
+
kind: 'field'
|
|
23
|
+
key: string
|
|
24
|
+
fieldType: 'input' | 'textarea' | 'select' | 'slider'
|
|
25
|
+
secret: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SubmissionRegistry {
|
|
29
|
+
members: ReadonlyMap<string, SubmissionMember>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SubmitInteractionState {
|
|
33
|
+
answers: Record<string, string>
|
|
34
|
+
multiAnswers: Record<string, string[]>
|
|
35
|
+
fields: Record<string, string>
|
|
36
|
+
secretFields: ReadonlySet<string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResolvedSubmitState {
|
|
40
|
+
scope: SubmissionMember[]
|
|
41
|
+
answered: number
|
|
42
|
+
total: number
|
|
43
|
+
localGradeEligible: boolean
|
|
44
|
+
hasOutOfScopePayload: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 从 spec 建立成员表,并诊断 submission key 和 submit.groups。 */
|
|
48
|
+
export function analyzeSubmissionRegistry(spec: GenuiSpec): { registry: SubmissionRegistry; diagnostics: string[] } {
|
|
49
|
+
const members = new Map<string, SubmissionMember>()
|
|
50
|
+
const paths = new Map<string, string>()
|
|
51
|
+
const diagnostics: string[] = []
|
|
52
|
+
const submits: Array<{ groups: string[]; path: string }> = []
|
|
53
|
+
|
|
54
|
+
walkGenuiNodes(spec, (node, path) => {
|
|
55
|
+
if (node.type === 'submit') {
|
|
56
|
+
if (node.groups !== undefined) submits.push({ groups: node.groups, path })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
let member: SubmissionMember | undefined
|
|
60
|
+
let keyPath: string
|
|
61
|
+
switch (node.type) {
|
|
62
|
+
case 'radio':
|
|
63
|
+
if (node.group === undefined) return
|
|
64
|
+
member = { kind: 'radio', key: node.group, label: node.label ?? node.group, options: node.options.slice(0, GENUI_LIMITS.maxOptions), ...(node.answer === undefined ? {} : { answer: node.answer }), ...(node.explanation === undefined ? {} : { explanation: node.explanation }) }
|
|
65
|
+
keyPath = `${path}.group`
|
|
66
|
+
break
|
|
67
|
+
case 'checkbox':
|
|
68
|
+
if (node.group === undefined) return
|
|
69
|
+
member = { kind: 'checkbox', key: node.group }
|
|
70
|
+
keyPath = `${path}.group`
|
|
71
|
+
break
|
|
72
|
+
case 'input':
|
|
73
|
+
case 'textarea':
|
|
74
|
+
case 'select':
|
|
75
|
+
case 'slider':
|
|
76
|
+
if (node.id === undefined) return
|
|
77
|
+
member = { kind: 'field', key: node.id, fieldType: node.type, secret: node.type === 'input' && node.inputType === 'password' }
|
|
78
|
+
keyPath = `${path}.id`
|
|
79
|
+
break
|
|
80
|
+
default:
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (member === undefined) return
|
|
84
|
+
const existing = members.get(member.key)
|
|
85
|
+
if (existing !== undefined) {
|
|
86
|
+
if (existing.kind !== 'checkbox' || member.kind !== 'checkbox') {
|
|
87
|
+
diagnostics.push(`${keyPath} conflicts with ${paths.get(member.key)}: submission key '${member.key}' is already used`)
|
|
88
|
+
}
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
members.set(member.key, member)
|
|
92
|
+
paths.set(member.key, keyPath)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
for (const { groups, path } of submits) {
|
|
96
|
+
const seen = new Set<string>()
|
|
97
|
+
groups.forEach((key, index) => {
|
|
98
|
+
const at = `${path}.groups[${index}]`
|
|
99
|
+
if (seen.has(key)) diagnostics.push(`${at}: duplicate submission member '${key}'`)
|
|
100
|
+
seen.add(key)
|
|
101
|
+
const member = members.get(key)
|
|
102
|
+
if (member === undefined) diagnostics.push(`${at}: submit.groups references unknown submission member '${key}'`)
|
|
103
|
+
else if (member.kind === 'field' && member.secret) diagnostics.push(`${at}: submit.groups cannot require secret field '${key}'`)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return { registry: { members }, diagnostics }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 编译供运行时读取的静态 submission 成员表。 */
|
|
110
|
+
export function compileSubmissionRegistry(spec: GenuiSpec): SubmissionRegistry {
|
|
111
|
+
return analyzeSubmissionRegistry(spec).registry
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 根据当前交互状态判断一个 submission member 是否完成。 */
|
|
115
|
+
export function isSubmissionMemberAnswered(member: SubmissionMember, state: SubmitInteractionState): boolean {
|
|
116
|
+
switch (member.kind) {
|
|
117
|
+
case 'radio': return state.answers[member.key] !== undefined
|
|
118
|
+
case 'checkbox': return (state.multiAnswers[member.key]?.length ?? 0) > 0
|
|
119
|
+
case 'field': {
|
|
120
|
+
if (member.secret) return false
|
|
121
|
+
const value = state.fields[member.key]
|
|
122
|
+
return value !== undefined && value.trim() !== ''
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 计算 submit 进度和纯本地判卷条件。 */
|
|
128
|
+
export function resolveSubmitState({ registry, groups, state }: {
|
|
129
|
+
registry: SubmissionRegistry
|
|
130
|
+
groups?: string[]
|
|
131
|
+
state: SubmitInteractionState
|
|
132
|
+
}): ResolvedSubmitState {
|
|
133
|
+
const hasUnknownMember = groups?.some(key => !registry.members.has(key)) ?? false
|
|
134
|
+
const scope = groups === undefined
|
|
135
|
+
? [...registry.members.values()].filter(member => isSubmissionMemberAnswered(member, state))
|
|
136
|
+
: groups.flatMap(key => {
|
|
137
|
+
const member = registry.members.get(key)
|
|
138
|
+
return member === undefined ? [] : [member]
|
|
139
|
+
})
|
|
140
|
+
const answered = scope.filter(member => isSubmissionMemberAnswered(member, state)).length
|
|
141
|
+
const total = groups?.length ?? scope.length
|
|
142
|
+
const scopeKeys = new Set(scope.map(member => member.key))
|
|
143
|
+
const hasOutOfScopePayload = Object.keys(state.answers).some(key => !scopeKeys.has(key))
|
|
144
|
+
|| Object.keys(state.multiAnswers).some(key => !scopeKeys.has(key))
|
|
145
|
+
|| Object.entries(state.fields).some(([key, value]) => value.trim() !== '' && !state.secretFields.has(key) && !scopeKeys.has(key))
|
|
146
|
+
const localGradeEligible = !hasUnknownMember && scope.length > 0
|
|
147
|
+
&& scope.every(member => member.kind === 'radio')
|
|
148
|
+
&& scope.some(member => member.kind === 'radio' && member.answer !== undefined)
|
|
149
|
+
return { scope, answered, total, localGradeEligible, hasOutOfScopePayload }
|
|
150
|
+
}
|
|
@@ -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
|
+
}
|