@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
|
@@ -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
|
|
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
|
|
197
|
+
if (holder === undefined) return child
|
|
200
198
|
const normalizedHolder = { ...holder }
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
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
|
}
|
package/src/client/guard.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
|
1556
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1642
1557
|
const step = steps[i]!
|
|
1643
|
-
|
|
1644
|
-
const list =
|
|
1645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
|
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
|
|
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':
|