@skopon-cool/form-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +82 -0
  2. package/dist/adapter/a2uiAdapter.d.ts +21 -0
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -0
  4. package/dist/adapter/extractSurfaceValues.d.ts +8 -0
  5. package/dist/adapter/extractSurfaceValues.d.ts.map +1 -0
  6. package/dist/adapter/formFileAccept.d.ts +17 -0
  7. package/dist/adapter/formFileAccept.d.ts.map +1 -0
  8. package/dist/adapter/formFilePlaceholderIcon.d.ts +5 -0
  9. package/dist/adapter/formFilePlaceholderIcon.d.ts.map +1 -0
  10. package/dist/adapter/formMedia.d.ts +7 -0
  11. package/dist/adapter/formMedia.d.ts.map +1 -0
  12. package/dist/adapter/formSchema.d.ts +6 -0
  13. package/dist/adapter/formSchema.d.ts.map +1 -0
  14. package/dist/adapter/id.d.ts +4 -0
  15. package/dist/adapter/id.d.ts.map +1 -0
  16. package/dist/adapter/resolveSurface.d.ts +6 -0
  17. package/dist/adapter/resolveSurface.d.ts.map +1 -0
  18. package/dist/catalog/a2uiCustomCatalog.d.ts +10 -0
  19. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -0
  20. package/dist/catalog/a2uiPreviewContext.d.ts +11 -0
  21. package/dist/catalog/a2uiPreviewContext.d.ts.map +1 -0
  22. package/dist/catalog/useSkoponBoundField.d.ts +10 -0
  23. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -0
  24. package/dist/client/formClient.d.ts +22 -0
  25. package/dist/client/formClient.d.ts.map +1 -0
  26. package/dist/components/AskUserFormCard.d.ts +13 -0
  27. package/dist/components/AskUserFormCard.d.ts.map +1 -0
  28. package/dist/components/CurlSubmitBlock.d.ts +10 -0
  29. package/dist/components/CurlSubmitBlock.d.ts.map +1 -0
  30. package/dist/components/SkoponA2uiStreamRenderer.d.ts +11 -0
  31. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -0
  32. package/dist/components/SkoponFormRenderer.d.ts +16 -0
  33. package/dist/components/SkoponFormRenderer.d.ts.map +1 -0
  34. package/dist/form-sdk.css +1 -0
  35. package/dist/icons/FilePlaceholderIcon.d.ts +10 -0
  36. package/dist/icons/FilePlaceholderIcon.d.ts.map +1 -0
  37. package/dist/index.d.ts +20 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +1332 -0
  40. package/dist/submit/buildCurlStatement.d.ts +2 -0
  41. package/dist/submit/buildCurlStatement.d.ts.map +1 -0
  42. package/dist/submit/intersectPayloadWithForm.d.ts +17 -0
  43. package/dist/submit/intersectPayloadWithForm.d.ts.map +1 -0
  44. package/dist/submit/submitFormJson.d.ts +12 -0
  45. package/dist/submit/submitFormJson.d.ts.map +1 -0
  46. package/dist/types/index.d.ts +76 -0
  47. package/dist/types/index.d.ts.map +1 -0
  48. package/package.json +53 -0
  49. package/src/adapter/a2uiAdapter.test.ts +150 -0
  50. package/src/adapter/a2uiAdapter.ts +490 -0
  51. package/src/adapter/extractSurfaceValues.ts +25 -0
  52. package/src/adapter/formFileAccept.ts +198 -0
  53. package/src/adapter/formFilePlaceholderIcon.ts +33 -0
  54. package/src/adapter/formMedia.ts +50 -0
  55. package/src/adapter/formSchema.ts +139 -0
  56. package/src/adapter/id.ts +24 -0
  57. package/src/adapter/resolveSurface.ts +66 -0
  58. package/src/catalog/a2uiCustomCatalog.tsx +548 -0
  59. package/src/catalog/a2uiPreviewContext.tsx +26 -0
  60. package/src/catalog/useSkoponBoundField.ts +57 -0
  61. package/src/client/formClient.ts +72 -0
  62. package/src/components/AskUserFormCard.tsx +155 -0
  63. package/src/components/CurlSubmitBlock.tsx +60 -0
  64. package/src/components/SkoponA2uiStreamRenderer.tsx +70 -0
  65. package/src/components/SkoponFormRenderer.tsx +100 -0
  66. package/src/icons/FilePlaceholderIcon.tsx +40 -0
  67. package/src/index.ts +67 -0
  68. package/src/styles/a2ui-preview.css +345 -0
  69. package/src/styles/index.css +190 -0
  70. package/src/submit/buildCurlStatement.ts +13 -0
  71. package/src/submit/intersectPayloadWithForm.ts +54 -0
  72. package/src/submit/submit.test.ts +63 -0
  73. package/src/submit/submitFormJson.ts +50 -0
  74. package/src/types/index.ts +139 -0
@@ -0,0 +1,490 @@
1
+ import type {
2
+ A2uiComponentNode,
3
+ A2uiSurfaceDoc,
4
+ FormBlock,
5
+ FormBlockOption,
6
+ FormBlockType,
7
+ FormMediaSize,
8
+ FormSchema,
9
+ } from '../types/index'
10
+ import { generateId } from './id'
11
+ import { getMediaUrls, normalizeMediaSize } from './formMedia'
12
+ import { syncFormDefinition } from './formSchema'
13
+
14
+ /**
15
+ * A2UI surface <-> FormBlock 双向适配器。
16
+ *
17
+ * - 内部编辑模型仍是扁平的 FormBlock[](编辑器、JSON Schema、校验都基于它)。
18
+ * - 对外/存储/AI 交换使用 A2UI v0.9 surface(basicCatalog + skopon 自定义 catalog)。
19
+ * - load/save 时在两者之间做无损(尽量)转换。
20
+ *
21
+ * 已知有损点(可接受,blocks 仍是存储 source-of-truth):
22
+ * email/tel/url 回读时统一退化为 text(除非可由 validationRegexp 推断)。
23
+ * A2UI ChoicePicker 无原生下拉;select/multiselect 使用 SkoponSelect 扩展组件。
24
+ */
25
+
26
+ const ROOT_ID = 'root'
27
+ const TITLE_ID = '__title__'
28
+ const DESC_ID = '__desc__'
29
+ const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
30
+
31
+ function pathFor(name: string): string {
32
+ return `/${name}`
33
+ }
34
+
35
+ function nameFromValue(value: unknown): string {
36
+ if (value && typeof value === 'object' && 'path' in (value as Record<string, unknown>)) {
37
+ const path = String((value as { path: unknown }).path ?? '')
38
+ return path.replace(/^\//, '')
39
+ }
40
+ return ''
41
+ }
42
+
43
+ function asLiteral(value: unknown): string | undefined {
44
+ if (typeof value === 'string') return value
45
+ return undefined
46
+ }
47
+
48
+
49
+ function mediaBlockNode(block: FormBlock): A2uiComponentNode {
50
+ const id = block.id || generateId('a2-')
51
+ const mediaType =
52
+ block.type === 'video' ? 'video' : block.type === 'audio' ? 'audio' : 'image'
53
+ return {
54
+ id,
55
+ component: 'SkoponMedia',
56
+ mediaType,
57
+ urls: getMediaUrls(block),
58
+ mediaSize: normalizeMediaSize(block.mediaSize),
59
+ label: block.label ?? '',
60
+ help: block.help ?? '',
61
+ placeholder: block.placeholder ?? '',
62
+ }
63
+ }
64
+
65
+ function readMediaUrls(node: A2uiComponentNode): string[] {
66
+ if (Array.isArray(node.urls)) {
67
+ return node.urls.map((u) => String(u).trim()).filter(Boolean)
68
+ }
69
+ const single = asLiteral(node.url)
70
+ return single?.trim() ? [single.trim()] : []
71
+ }
72
+
73
+ function readMediaBlock(
74
+ node: A2uiComponentNode,
75
+ type: 'image' | 'video' | 'audio',
76
+ fallbackLabel: string,
77
+ ): FormBlock {
78
+ const id = typeof node.id === 'string' ? node.id : generateId('block-')
79
+ const urls = readMediaUrls(node)
80
+ const label = asLiteral(node.label) ?? asLiteral(node.description) ?? fallbackLabel
81
+ const help = asLiteral(node.help)
82
+ const placeholder = asLiteral(node.placeholder)
83
+ const mediaSize = normalizeMediaSize(node.mediaSize as FormMediaSize | undefined)
84
+ return {
85
+ id,
86
+ type,
87
+ label,
88
+ ...(help ? { help } : {}),
89
+ ...(placeholder ? { placeholder } : {}),
90
+ mediaSize,
91
+ mediaUrls: urls.length > 0 ? urls : [''],
92
+ }
93
+ }
94
+
95
+ function defaultValueForBlock(block: FormBlock): unknown {
96
+ switch (block.type) {
97
+ case 'toggle':
98
+ return typeof block.defaultValue === 'boolean' ? block.defaultValue : false
99
+ case 'multiselect':
100
+ case 'checkbox':
101
+ case 'select':
102
+ case 'radio':
103
+ if (Array.isArray(block.defaultValue)) return block.defaultValue
104
+ if (typeof block.defaultValue === 'string' && block.defaultValue) return [block.defaultValue]
105
+ return []
106
+ default:
107
+ if (typeof block.defaultValue === 'string') return block.defaultValue
108
+ return ''
109
+ }
110
+ }
111
+
112
+ /** 单个 FormBlock -> A2UI 组件节点(input 类附带 dataModel 默认值) */
113
+ function blockToComponent(block: FormBlock): {
114
+ node: A2uiComponentNode | null
115
+ dataKey?: string
116
+ dataValue?: unknown
117
+ } {
118
+ const id = block.id || generateId('a2-')
119
+ const label = block.label ?? ''
120
+ const name = block.name?.trim() || ''
121
+ const path = name ? pathFor(name) : undefined
122
+ const options = (block.options ?? []).map((o) => ({ label: o.label, value: o.value }))
123
+
124
+ const withData = (node: A2uiComponentNode): {
125
+ node: A2uiComponentNode
126
+ dataKey?: string
127
+ dataValue?: unknown
128
+ } => (name ? { node, dataKey: name, dataValue: defaultValueForBlock(block) } : { node })
129
+
130
+ switch (block.type) {
131
+ case 'heading':
132
+ return { node: { id, component: 'Text', text: label, variant: 'h3' } }
133
+ case 'paragraph':
134
+ return { node: { id, component: 'Text', text: label, variant: 'body' } }
135
+
136
+ case 'text':
137
+ case 'tel':
138
+ case 'url':
139
+ return withData({ id, component: 'TextField', label, variant: 'shortText', value: { path } })
140
+ case 'email':
141
+ return withData({
142
+ id,
143
+ component: 'TextField',
144
+ label,
145
+ variant: 'shortText',
146
+ validationRegexp: EMAIL_REGEXP,
147
+ value: { path },
148
+ })
149
+ case 'textarea':
150
+ return withData({ id, component: 'TextField', label, variant: 'longText', value: { path } })
151
+ case 'number':
152
+ return withData({ id, component: 'TextField', label, variant: 'number', value: { path } })
153
+
154
+ case 'select':
155
+ return withData({
156
+ id,
157
+ component: 'SkoponSelect',
158
+ label,
159
+ mode: 'single',
160
+ placeholder: block.placeholder ?? '',
161
+ options,
162
+ value: { path },
163
+ })
164
+ case 'radio':
165
+ return withData({
166
+ id,
167
+ component: 'ChoicePicker',
168
+ label,
169
+ variant: 'mutuallyExclusive',
170
+ skoponPickerType: 'radio',
171
+ options,
172
+ value: { path },
173
+ })
174
+ case 'multiselect':
175
+ return withData({
176
+ id,
177
+ component: 'SkoponSelect',
178
+ label,
179
+ mode: 'multiple',
180
+ placeholder: block.placeholder ?? '',
181
+ options,
182
+ value: { path },
183
+ })
184
+ case 'checkbox':
185
+ return withData({
186
+ id,
187
+ component: 'ChoicePicker',
188
+ label,
189
+ variant: 'multipleSelection',
190
+ skoponPickerType: 'checkbox',
191
+ options,
192
+ value: { path },
193
+ })
194
+
195
+ case 'toggle':
196
+ return withData({ id, component: 'CheckBox', label, value: { path } })
197
+
198
+ case 'datetime':
199
+ return withData({
200
+ id,
201
+ component: 'DateTimeInput',
202
+ label,
203
+ enableDate: true,
204
+ enableTime: true,
205
+ placeholder: block.placeholder ?? '',
206
+ value: { path },
207
+ })
208
+ case 'time':
209
+ return withData({
210
+ id,
211
+ component: 'DateTimeInput',
212
+ label,
213
+ enableTime: true,
214
+ placeholder: block.placeholder ?? '',
215
+ value: { path },
216
+ })
217
+
218
+ case 'image':
219
+ case 'video':
220
+ case 'audio':
221
+ return { node: mediaBlockNode(block) }
222
+
223
+ case 'file':
224
+ return withData({
225
+ id,
226
+ component: 'FileUpload',
227
+ label,
228
+ accept: (block.fileAcceptExtensions ?? []).join(','),
229
+ fileAcceptTypes: block.fileAcceptTypes ?? [],
230
+ filePlaceholderIcon: block.filePlaceholderIcon ?? 'document',
231
+ minCount: block.fileMinCount ?? 0,
232
+ maxCount: block.fileMaxCount ?? 1,
233
+ value: { path },
234
+ })
235
+
236
+ default:
237
+ return { node: null }
238
+ }
239
+ }
240
+
241
+ export interface BlocksToA2uiOptions {
242
+ styleId?: string | null
243
+ /** 是否写入 title/description 为 Text 组件;预览区由 FormTitleField 展示时可设为 false */
244
+ includeHeader?: boolean
245
+ }
246
+
247
+ /** surface 是否仅含空 root Column(无可视内容) */
248
+ export function isA2uiSurfaceEmpty(doc: A2uiSurfaceDoc | null | undefined): boolean {
249
+ if (!doc || !Array.isArray(doc.components) || doc.components.length === 0) return true
250
+ const root = doc.components.find((c) => c.id === ROOT_ID)
251
+ if (!root) return doc.components.length === 0
252
+ const children = root.children
253
+ if (!Array.isArray(children) || children.length === 0) return true
254
+ return false
255
+ }
256
+
257
+ /** FormBlock[] -> A2UI surface 文档 */
258
+ export function blocksToA2ui(
259
+ definition: Pick<FormSchema, 'title' | 'description' | 'blocks'>,
260
+ options?: BlocksToA2uiOptions,
261
+ ): A2uiSurfaceDoc {
262
+ const components: A2uiComponentNode[] = []
263
+ const childIds: string[] = []
264
+ const dataModel: Record<string, unknown> = {}
265
+
266
+ const includeHeader = options?.includeHeader !== false
267
+ const title = (definition.title ?? '').trim()
268
+ const description = (definition.description ?? '').trim()
269
+ if (includeHeader && title) {
270
+ components.push({ id: TITLE_ID, component: 'Text', text: title, variant: 'h2' })
271
+ childIds.push(TITLE_ID)
272
+ }
273
+ if (includeHeader && description) {
274
+ components.push({ id: DESC_ID, component: 'Text', text: description, variant: 'body' })
275
+ childIds.push(DESC_ID)
276
+ }
277
+
278
+ for (const block of definition.blocks ?? []) {
279
+ const { node, dataKey, dataValue } = blockToComponent(block)
280
+ if (!node) continue
281
+ components.push(node)
282
+ childIds.push(node.id)
283
+ if (dataKey) dataModel[dataKey] = dataValue
284
+ }
285
+
286
+ components.unshift({ id: ROOT_ID, component: 'Column', children: childIds })
287
+
288
+ const doc: A2uiSurfaceDoc = { root: ROOT_ID, components, dataModel }
289
+ const styleId = options?.styleId
290
+ if (styleId !== undefined) {
291
+ doc.surfaceProperties = { styleId: styleId ?? null }
292
+ }
293
+ return doc
294
+ }
295
+
296
+ function indexComponents(doc: A2uiSurfaceDoc): Map<string, A2uiComponentNode> {
297
+ const map = new Map<string, A2uiComponentNode>()
298
+ for (const node of doc.components ?? []) {
299
+ if (node && typeof node.id === 'string') map.set(node.id, node)
300
+ }
301
+ return map
302
+ }
303
+
304
+ function readOptions(node: A2uiComponentNode): FormBlockOption[] {
305
+ const raw = node.options
306
+ if (!Array.isArray(raw)) return []
307
+ return raw
308
+ .map((o) => {
309
+ if (!o || typeof o !== 'object') return null
310
+ const rec = o as Record<string, unknown>
311
+ return {
312
+ value: String(rec.value ?? rec.label ?? ''),
313
+ label: String(rec.label ?? rec.value ?? ''),
314
+ } as FormBlockOption
315
+ })
316
+ .filter((o): o is FormBlockOption => !!o)
317
+ }
318
+
319
+ /** 单个 A2UI 组件节点 -> FormBlock(无法识别返回 null) */
320
+ function componentToBlock(node: A2uiComponentNode): FormBlock | null {
321
+ const id = typeof node.id === 'string' ? node.id : generateId('block-')
322
+ const component = String(node.component ?? '')
323
+ const label = asLiteral(node.label) ?? asLiteral(node.text) ?? ''
324
+ const name = nameFromValue(node.value)
325
+
326
+ const base = (type: FormBlockType): FormBlock => ({ id, type, label })
327
+
328
+ switch (component) {
329
+ case 'Text': {
330
+ const variant = String(node.variant ?? 'body')
331
+ const isHeading = /^h[1-6]$/i.test(variant) || variant === 'title' || variant === 'subtitle'
332
+ return { id, type: isHeading ? 'heading' : 'paragraph', label }
333
+ }
334
+ case 'TextField': {
335
+ const variant = String(node.variant ?? 'shortText')
336
+ let type: FormBlockType = 'text'
337
+ if (variant === 'longText') type = 'textarea'
338
+ else if (variant === 'number') type = 'number'
339
+ else if (asLiteral(node.validationRegexp) === EMAIL_REGEXP) type = 'email'
340
+ return { ...base(type), name }
341
+ }
342
+ case 'CheckBox':
343
+ return { ...base('toggle'), name }
344
+ case 'SkoponSelect': {
345
+ const mode = String(node.mode ?? 'single')
346
+ const type: FormBlockType = mode === 'multiple' ? 'multiselect' : 'select'
347
+ const block: FormBlock = {
348
+ ...base(type),
349
+ name,
350
+ options: readOptions(node),
351
+ }
352
+ const placeholder = asLiteral(node.placeholder)
353
+ if (placeholder) block.placeholder = placeholder
354
+ return block
355
+ }
356
+ case 'ChoicePicker': {
357
+ const pickerType = asLiteral(node.skoponPickerType)
358
+ if (
359
+ pickerType === 'select' ||
360
+ pickerType === 'multiselect' ||
361
+ pickerType === 'radio' ||
362
+ pickerType === 'checkbox'
363
+ ) {
364
+ const block: FormBlock = {
365
+ ...base(pickerType),
366
+ name,
367
+ options: readOptions(node),
368
+ }
369
+ const placeholder = asLiteral(node.placeholder)
370
+ if (placeholder) block.placeholder = placeholder
371
+ return block
372
+ }
373
+ const variant = String(node.variant ?? 'mutuallyExclusive')
374
+ const type: FormBlockType = variant === 'multipleSelection' ? 'checkbox' : 'radio'
375
+ return { ...base(type), name, options: readOptions(node) }
376
+ }
377
+ case 'DateTimeInput': {
378
+ const enableDate = node.enableDate === true
379
+ const enableTime = node.enableTime === true
380
+ const type: FormBlockType = !enableDate && enableTime ? 'time' : 'datetime'
381
+ const block: FormBlock = { ...base(type), name }
382
+ const placeholder = asLiteral(node.placeholder)
383
+ if (placeholder) block.placeholder = placeholder
384
+ return block
385
+ }
386
+ case 'SkoponMedia': {
387
+ const mediaType = String(node.mediaType ?? 'image')
388
+ const type: FormBlockType =
389
+ mediaType === 'video' ? 'video' : mediaType === 'audio' ? 'audio' : 'image'
390
+ return readMediaBlock(node, type, label)
391
+ }
392
+ case 'Image':
393
+ return readMediaBlock(node, 'image', label)
394
+ case 'Video':
395
+ return readMediaBlock(node, 'video', label)
396
+ case 'AudioPlayer':
397
+ return readMediaBlock(node, 'audio', label)
398
+ case 'FileUpload': {
399
+ const accept = asLiteral(node.accept) ?? ''
400
+ const block: FormBlock = { ...base('file'), name }
401
+ if (accept) block.fileAcceptExtensions = accept.split(',').map((s) => s.trim()).filter(Boolean)
402
+ if (Array.isArray(node.fileAcceptTypes)) {
403
+ block.fileAcceptTypes = node.fileAcceptTypes.map(String)
404
+ }
405
+ if (typeof node.filePlaceholderIcon === 'string') {
406
+ block.filePlaceholderIcon = node.filePlaceholderIcon as FormBlock['filePlaceholderIcon']
407
+ }
408
+ if (typeof node.minCount === 'number') block.fileMinCount = node.minCount
409
+ if (typeof node.maxCount === 'number') block.fileMaxCount = node.maxCount
410
+ return block
411
+ }
412
+ default:
413
+ return null
414
+ }
415
+ }
416
+
417
+ /** 递归收集容器下的叶子组件顺序(扁平化 Column/Row 嵌套) */
418
+ function collectOrder(
419
+ rootId: string,
420
+ index: Map<string, A2uiComponentNode>,
421
+ seen: Set<string>,
422
+ out: A2uiComponentNode[],
423
+ ): void {
424
+ if (seen.has(rootId)) return
425
+ seen.add(rootId)
426
+ const node = index.get(rootId)
427
+ if (!node) return
428
+ const component = String(node.component ?? '')
429
+ if ((component === 'Column' || component === 'Row') && Array.isArray(node.children)) {
430
+ for (const childId of node.children) {
431
+ if (typeof childId === 'string') collectOrder(childId, index, seen, out)
432
+ }
433
+ return
434
+ }
435
+ out.push(node)
436
+ }
437
+
438
+ /** A2UI surface 文档 -> FormSchema(已 sync 出 jsonSchema) */
439
+ export function a2uiToBlocks(doc: A2uiSurfaceDoc | null | undefined): FormSchema {
440
+ if (!doc || !Array.isArray(doc.components)) {
441
+ return syncFormDefinition({ title: '', description: '', blocks: [], jsonSchema: {} })
442
+ }
443
+ const index = indexComponents(doc)
444
+ const ordered: A2uiComponentNode[] = []
445
+ collectOrder(doc.root ?? ROOT_ID, index, new Set(), ordered)
446
+
447
+ let title = ''
448
+ let description = ''
449
+ const blocks: FormBlock[] = []
450
+
451
+ for (const node of ordered) {
452
+ if (node.id === TITLE_ID) {
453
+ title = asLiteral(node.text) ?? title
454
+ continue
455
+ }
456
+ if (node.id === DESC_ID) {
457
+ description = asLiteral(node.text) ?? description
458
+ continue
459
+ }
460
+ const block = componentToBlock(node)
461
+ if (block) blocks.push(block)
462
+ }
463
+
464
+ return syncFormDefinition({ title, description, blocks, jsonSchema: {} })
465
+ }
466
+
467
+ /**
468
+ * 便携 surface 文档 -> A2UI v0.9 消息列表(供 MessageProcessor 消费)。
469
+ * 返回宽松类型,调用方按需 `as unknown as A2uiMessage[]`。
470
+ */
471
+ export function surfaceDocToMessages(
472
+ doc: A2uiSurfaceDoc,
473
+ options: { surfaceId: string; catalogId: string },
474
+ ): Record<string, unknown>[] {
475
+ const { surfaceId, catalogId } = options
476
+ const messages: Record<string, unknown>[] = [
477
+ { version: 'v0.9', createSurface: { surfaceId, catalogId } },
478
+ {
479
+ version: 'v0.9',
480
+ updateComponents: { surfaceId, components: doc.components },
481
+ },
482
+ ]
483
+ if (doc.dataModel && Object.keys(doc.dataModel).length > 0) {
484
+ messages.push({
485
+ version: 'v0.9',
486
+ updateDataModel: { surfaceId, path: '/', value: doc.dataModel },
487
+ })
488
+ }
489
+ return messages
490
+ }
@@ -0,0 +1,25 @@
1
+ import type { MessageProcessor } from '@a2ui/web_core/v0_9'
2
+ import type { ReactComponentImplementation } from '@a2ui/react/v0_9'
3
+
4
+ type SkoponProcessor = MessageProcessor<ReactComponentImplementation>
5
+
6
+ /** 从 MessageProcessor surface 的 dataModel 读取已填字段值。 */
7
+ export function extractSurfaceValues(
8
+ processor: SkoponProcessor,
9
+ surfaceId: string,
10
+ fieldNames: string[],
11
+ ): Record<string, unknown> {
12
+ const surface = processor.model.surfacesMap.get(surfaceId)
13
+ if (!surface) return {}
14
+ const values: Record<string, unknown> = {}
15
+ for (const name of fieldNames) {
16
+ const trimmed = name.trim()
17
+ if (!trimmed) continue
18
+ values[trimmed] = surface.dataModel.get(`/${trimmed}`)
19
+ }
20
+ return values
21
+ }
22
+
23
+ export function collectInputFieldNames(fieldNames?: string[]): string[] {
24
+ return (fieldNames ?? []).map((name) => name.trim()).filter(Boolean)
25
+ }