@changfenhuang/dsh-genui 0.11.2 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +13 -4
- package/README.zh-CN.md +13 -4
- package/SKILL.md +16 -4
- package/lib/assets/standalone-runtime.js +7 -6
- package/lib/client.js +15 -17
- package/lib/index.js +956 -354
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/spec.d.ts +8 -11
- package/lib/types/client/submission-registry.d.ts +51 -0
- package/lib/types/client/table-details.d.ts +20 -0
- package/lib/types/client/walk-spec.d.ts +3 -0
- package/lib/types/plugin/fence-feedback.d.ts +50 -11
- package/lib/types/plugin/index.d.ts +3 -3
- package/lib/types/shared/fence-repair.d.ts +18 -7
- package/package.json +20 -19
- package/src/client/GenuiBlock.module.css +65 -1
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/blocks/charts.tsx +41 -8
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +1 -1
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +94 -21
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +93 -151
- package/src/client/inline.ts +11 -4
- package/src/client/spec.ts +8 -11
- package/src/client/submission-registry.ts +150 -0
- package/src/client/table-details.ts +151 -0
- package/src/client/walk-spec.ts +43 -0
- package/src/plugin/fence-feedback.ts +372 -53
- package/src/plugin/index.ts +7 -6
- package/src/shared/fence-repair.ts +309 -128
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GenuiNode, GenuiSpec } from './spec.ts'
|
|
2
|
+
import { GENUI_LIMITS } from './genui-runtime/index.ts'
|
|
3
|
+
import { isTableDetailReachable, tableRowsForDetails } from './table-details.ts'
|
|
4
|
+
|
|
5
|
+
/** 遍历完整 GenUI 组件树,并提供每个组件在 spec 中的路径。 */
|
|
6
|
+
export function walkGenuiNodes(spec: GenuiSpec, visitor: (node: GenuiNode, path: string) => void): void {
|
|
7
|
+
const walk = (nodes: GenuiNode[], path: string, depth: number): void => {
|
|
8
|
+
if (depth > GENUI_LIMITS.maxDepth) return
|
|
9
|
+
nodes.forEach((node, index) => visit(node, `${path}[${index}]`, depth))
|
|
10
|
+
}
|
|
11
|
+
const visit = (node: GenuiNode, at: string, depth: number): void => {
|
|
12
|
+
if (depth > GENUI_LIMITS.maxDepth) return
|
|
13
|
+
visitor(node, at)
|
|
14
|
+
if (depth >= GENUI_LIMITS.maxDepth) return
|
|
15
|
+
switch (node.type) {
|
|
16
|
+
case 'row':
|
|
17
|
+
case 'col':
|
|
18
|
+
case 'grid':
|
|
19
|
+
case 'card':
|
|
20
|
+
walk(node.items, `${at}.items`, depth + 1)
|
|
21
|
+
break
|
|
22
|
+
case 'tabs':
|
|
23
|
+
node.tabs.forEach((tab, tabIndex) => walk(tab.items, `${at}.tabs[${tabIndex}].items`, depth + 1))
|
|
24
|
+
break
|
|
25
|
+
case 'accordion':
|
|
26
|
+
node.items.forEach((item, itemIndex) => walk(item.items, `${at}.items[${itemIndex}].items`, depth + 1))
|
|
27
|
+
break
|
|
28
|
+
case 'list':
|
|
29
|
+
node.items.forEach((item, itemIndex) => {
|
|
30
|
+
if (item !== null && typeof item === 'object' && 'type' in item) visit(item, `${at}.items[${itemIndex}]`, depth + 1)
|
|
31
|
+
})
|
|
32
|
+
break
|
|
33
|
+
case 'table':
|
|
34
|
+
node.details?.slice(0, tableRowsForDetails(node).length).forEach((detail, rowIndex) => {
|
|
35
|
+
if (detail !== null && isTableDetailReachable(node, rowIndex)) {
|
|
36
|
+
walk(detail, `${at}.details[${rowIndex}]`, depth + 1)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(spec.items, 'items', 0)
|
|
43
|
+
}
|