@skopon-cool/form-sdk 0.1.1 → 0.1.3

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 (51) hide show
  1. package/README.md +47 -11
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formFileAccept.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts +1 -0
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  7. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  8. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  9. package/dist/client/formClient.d.ts.map +1 -1
  10. package/dist/components/AskUserFormCard.d.ts +3 -1
  11. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  12. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  13. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
  14. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
  15. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  16. package/dist/form-sdk.css +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +941 -706
  20. package/dist/submit/buildCurlStatement.d.ts +8 -0
  21. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  22. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
  23. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
  24. package/dist/submit/submitFormJson.d.ts.map +1 -1
  25. package/package.json +11 -6
  26. package/src/adapter/a2uiAdapter.test.ts +68 -0
  27. package/src/adapter/a2uiAdapter.ts +7 -3
  28. package/src/adapter/formFileAccept.test.ts +53 -0
  29. package/src/adapter/formFileAccept.ts +11 -2
  30. package/src/adapter/formSchema.test.ts +35 -0
  31. package/src/adapter/formSchema.ts +5 -1
  32. package/src/catalog/a2uiCustomCatalog.tsx +28 -5
  33. package/src/catalog/textFieldPreview.test.tsx +1 -1
  34. package/src/catalog/useSkoponBoundField.test.ts +62 -0
  35. package/src/catalog/useSkoponBoundField.ts +10 -1
  36. package/src/client/formClient.test.ts +83 -0
  37. package/src/client/formClient.ts +10 -2
  38. package/src/components/AskUserFormCard.tsx +136 -58
  39. package/src/components/SkoponA2uiStreamRenderer.test.ts +62 -0
  40. package/src/components/SkoponA2uiStreamRenderer.test.tsx +79 -0
  41. package/src/components/SkoponA2uiStreamRenderer.tsx +96 -15
  42. package/src/components/SkoponFormRenderer.tsx +10 -7
  43. package/src/index.ts +11 -2
  44. package/src/styles/index.css +5 -0
  45. package/src/submit/buildCurlStatement.ts +49 -0
  46. package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
  47. package/src/submit/submit.test.ts +170 -10
  48. package/src/submit/submitFormJson.ts +20 -1
  49. package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
  50. package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
  51. package/src/submit/intersectPayloadWithForm.ts +0 -54
@@ -1,18 +1,40 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
- import { Button, Spin, Tooltip } from 'antd'
2
+ import { Button, Spin, Tag, Tooltip } from 'antd'
3
3
  import type { FormDetailResult, FormSchema, SubmitMode } from '../types/index'
4
4
  import { buildAskUserSurface } from '../adapter/resolveSurface'
5
- import { buildCurlStatement } from '../submit/buildCurlStatement'
6
- import { intersectPayloadWithForm } from '../submit/intersectPayloadWithForm'
5
+ import { buildAskUserCurlStatement } from '../submit/buildCurlStatement'
6
+ import {
7
+ extractExtraBlockValues,
8
+ getPayloadInputFieldNames,
9
+ getPayloadRenderableBlocks,
10
+ intersectPayloadBlocksWithForm,
11
+ parsePayloadBlocksJson,
12
+ payloadHasInputBlocks,
13
+ } from '../submit/intersectPayloadBlocksWithForm'
7
14
  import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
8
15
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
9
16
  import CurlSubmitBlock from './CurlSubmitBlock'
10
17
 
18
+ function formatSubmitErrorMessage(status: number, body: unknown): string {
19
+ if (body && typeof body === 'object' && body !== null && 'message' in body) {
20
+ const msg = (body as { message: unknown }).message
21
+ if (typeof msg === 'string' && msg.trim()) {
22
+ return `提交失败 (${status}): ${msg.trim()}`
23
+ }
24
+ }
25
+ if (typeof body === 'string' && body.trim()) {
26
+ return `提交失败 (${status}): ${body.trim()}`
27
+ }
28
+ return `提交失败 (${status})`
29
+ }
30
+
11
31
  export interface AskUserFormCardProps {
12
32
  payload: unknown
13
- formUniqueId: string
33
+ /** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
34
+ formUniqueId?: string | null
14
35
  callbackUrl?: string | null
15
36
  submitMode?: SubmitMode
37
+ /** 需 useCallback 稳定引用,避免重复拉取 */
16
38
  fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
17
39
  onNotify?: (type: 'success' | 'error', message: string) => void
18
40
  }
@@ -28,22 +50,46 @@ export default function AskUserFormCard({
28
50
  const [formDef, setFormDef] = useState<FormSchema | null>(null)
29
51
  const [formDisabled, setFormDisabled] = useState(false)
30
52
  const [loading, setLoading] = useState(true)
53
+ const [submitting, setSubmitting] = useState(false)
31
54
  const rendererRef = useRef<SkoponFormRendererRef>(null)
55
+ const mountedRef = useRef(true)
56
+ const onNotifyRef = useRef(onNotify)
57
+ onNotifyRef.current = onNotify
58
+
59
+ const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
60
+
61
+ useEffect(() => {
62
+ mountedRef.current = true
63
+ return () => {
64
+ mountedRef.current = false
65
+ }
66
+ }, [])
32
67
 
33
68
  useEffect(() => {
34
69
  let cancelled = false
70
+ if (!resolvedFormUniqueId) {
71
+ setLoading(false)
72
+ setFormDisabled(false)
73
+ setFormDef(null)
74
+ return () => {
75
+ cancelled = true
76
+ }
77
+ }
78
+
35
79
  setLoading(true)
36
80
  setFormDisabled(false)
37
- fetchFormDetail({ formUniqueId })
81
+ fetchFormDetail({ formUniqueId: resolvedFormUniqueId })
38
82
  .then((detail) => {
39
83
  if (cancelled) return
40
84
  setFormDisabled(Boolean(detail.disabled))
41
- setFormDef(detail.disabled ? null : detail.formDefinition ?? null)
85
+ setFormDef(detail.formDefinition ?? null)
42
86
  })
43
- .catch(() => {
87
+ .catch((error) => {
44
88
  if (!cancelled) {
45
89
  setFormDisabled(false)
46
90
  setFormDef(null)
91
+ const detail = error instanceof Error ? error.message : '获取表单定义失败'
92
+ onNotifyRef.current?.('error', detail)
47
93
  }
48
94
  })
49
95
  .finally(() => {
@@ -52,25 +98,49 @@ export default function AskUserFormCard({
52
98
  return () => {
53
99
  cancelled = true
54
100
  }
55
- }, [formUniqueId, fetchFormDetail])
101
+ }, [resolvedFormUniqueId, fetchFormDetail])
56
102
 
57
- const { matchedBlocks, remainderPayload } = useMemo(
58
- () => intersectPayloadWithForm(payload, formDef ?? undefined),
59
- [payload, formDef],
60
- )
103
+ const payloadDef = useMemo(() => parsePayloadBlocksJson(payload), [payload])
104
+
105
+ const intersection = useMemo(() => {
106
+ if (!payloadDef) return null
107
+ return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined)
108
+ }, [payloadDef, formDef])
109
+
110
+ const matchedBlocks = intersection?.matchedBlocks ?? []
111
+ const usePayloadFallback =
112
+ Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
61
113
 
62
- const fieldNames = useMemo(
63
- () =>
64
- matchedBlocks
65
- .map((block) => block.name?.trim())
66
- .filter((name): name is string => Boolean(name)),
67
- [matchedBlocks],
114
+ const renderBlocks = useMemo(() => {
115
+ if (intersection && intersection.renderBlocks.length > 0) {
116
+ return intersection.renderBlocks
117
+ }
118
+ if (usePayloadFallback && payloadDef) return getPayloadRenderableBlocks(payloadDef)
119
+ return []
120
+ }, [intersection, usePayloadFallback, payloadDef])
121
+
122
+ const extraValues = useMemo(
123
+ () => (usePayloadFallback ? {} : extractExtraBlockValues(intersection?.extraBlocks ?? [])),
124
+ [usePayloadFallback, intersection],
68
125
  )
69
126
 
127
+ const fieldNames = useMemo(() => {
128
+ if (usePayloadFallback && payloadDef) return getPayloadInputFieldNames(payloadDef)
129
+ return renderBlocks
130
+ .filter((block) => block.name?.trim())
131
+ .map((block) => block.name!.trim())
132
+ }, [usePayloadFallback, payloadDef, renderBlocks])
133
+
70
134
  const surfaceDoc = useMemo(() => {
71
- if (!formDef || matchedBlocks.length === 0) return null
72
- return buildAskUserSurface(formDef, matchedBlocks)
73
- }, [formDef, matchedBlocks])
135
+ if (renderBlocks.length === 0) return null
136
+ const title = intersection?.title ?? payloadDef?.title ?? ''
137
+ const description = intersection?.description ?? payloadDef?.description ?? ''
138
+ return buildAskUserSurface({ title, description }, renderBlocks)
139
+ }, [intersection, payloadDef, renderBlocks])
140
+
141
+ const surfaceId = resolvedFormUniqueId
142
+ ? `ask-user-${resolvedFormUniqueId}`
143
+ : 'ask-user-payload'
74
144
 
75
145
  if (loading) {
76
146
  return (
@@ -80,76 +150,84 @@ export default function AskUserFormCard({
80
150
  )
81
151
  }
82
152
 
83
- if (matchedBlocks.length === 0) {
153
+ if (!payloadDef || renderBlocks.length === 0) {
84
154
  return (
85
155
  <CurlSubmitBlock
86
156
  payload={payload}
87
157
  callbackUrl={callbackUrl}
88
- unpublishedFormId={formDisabled ? formUniqueId : undefined}
158
+ unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
89
159
  onNotify={onNotify}
90
160
  />
91
161
  )
92
162
  }
93
163
 
94
- const hasRemainder = Object.keys(remainderPayload).length > 0
95
-
96
164
  async function handleSubmit() {
97
- const values = rendererRef.current?.getValues(fieldNames) ?? {}
98
- if (submitMode === 'post') {
99
- const url = (callbackUrl ?? '').trim()
100
- if (!url) {
101
- onNotify?.('error', 'callback_url 为空,无法提交')
102
- return
103
- }
104
- try {
105
- const result = await submitFormJson(url, values)
165
+ if (submitting) return
166
+ setSubmitting(true)
167
+ try {
168
+ const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
169
+ const submitBody = { ...cardValues, ...extraValues }
170
+
171
+ if (submitMode === 'post') {
172
+ const url = (callbackUrl ?? '').trim()
173
+ if (!url) {
174
+ onNotifyRef.current?.('error', 'callback_url 为空,无法提交')
175
+ return
176
+ }
177
+ const result = await submitFormJson(url, submitBody)
178
+ if (!mountedRef.current) return
106
179
  if (result.ok) {
107
- onNotify?.('success', '提交成功')
180
+ onNotifyRef.current?.('success', '提交成功')
108
181
  } else {
109
- onNotify?.('error', `提交失败 (${result.status})`)
182
+ onNotifyRef.current?.('error', formatSubmitErrorMessage(result.status, result.body))
110
183
  }
111
- } catch (error) {
112
- const detail = error instanceof Error ? error.message : '提交失败'
113
- onNotify?.('error', detail)
184
+ return
114
185
  }
115
- return
116
- }
117
186
 
118
- try {
119
- await copyTextToClipboard(buildCurlStatement(values, callbackUrl))
120
- onNotify?.('success', '已复制 curl 到剪贴板')
121
- } catch {
122
- onNotify?.('error', '复制失败')
187
+ await copyTextToClipboard(
188
+ buildAskUserCurlStatement({ cardValues, extraValues, callbackUrl }),
189
+ )
190
+ if (!mountedRef.current) return
191
+ onNotifyRef.current?.('success', '已复制 curl 到剪贴板')
192
+ } catch (error) {
193
+ if (!mountedRef.current) return
194
+ const detail = error instanceof Error ? error.message : '复制失败'
195
+ onNotifyRef.current?.('error', detail)
196
+ } finally {
197
+ if (mountedRef.current) setSubmitting(false)
123
198
  }
124
199
  }
125
200
 
126
201
  return (
127
202
  <div className="ask-user-form-card">
203
+ {formDisabled && resolvedFormUniqueId ? (
204
+ <div className="ask-user-form-card-status">
205
+ <Tooltip title={resolvedFormUniqueId}>
206
+ <Tag className="ask-user-curl-unpublished-tag">卡片 ID 未发布</Tag>
207
+ </Tooltip>
208
+ </div>
209
+ ) : null}
128
210
  <SkoponFormRenderer
129
211
  ref={rendererRef}
130
212
  doc={surfaceDoc}
131
- surfaceId={`ask-user-${formUniqueId}`}
213
+ surfaceId={surfaceId}
132
214
  fieldNames={fieldNames}
133
215
  interactive
134
216
  />
135
217
 
136
218
  <div className="ask-user-form-actions">
137
219
  <Tooltip title={submitMode === 'curl' ? '点击复制 curl' : '提交 JSON 到 callback_url'}>
138
- <Button type="primary" size="small" onClick={() => void handleSubmit()}>
220
+ <Button
221
+ type="primary"
222
+ size="small"
223
+ loading={submitting}
224
+ disabled={submitting}
225
+ onClick={() => void handleSubmit()}
226
+ >
139
227
  提交
140
228
  </Button>
141
229
  </Tooltip>
142
230
  </div>
143
-
144
- {hasRemainder ? (
145
- <CurlSubmitBlock
146
- payload={remainderPayload}
147
- callbackUrl={callbackUrl}
148
- title="以下字段不在表单中,请使用 curl 提交"
149
- incompleteFormId={formUniqueId}
150
- onNotify={onNotify}
151
- />
152
- ) : null}
153
231
  </div>
154
232
  )
155
233
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import { MessageProcessor, type A2uiMessage } from '@a2ui/web_core/v0_9'
6
+ import type { ReactComponentImplementation } from '@a2ui/react/v0_9'
7
+ import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
8
+ import { buildSkoponCatalog, SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
9
+
10
+ function createProcessor(): MessageProcessor<ReactComponentImplementation> {
11
+ return new MessageProcessor([buildSkoponCatalog()])
12
+ }
13
+
14
+ function buildStreamMessages(surfaceId: string): A2uiMessage[] {
15
+ const doc = blocksToA2ui(
16
+ {
17
+ title: '',
18
+ description: '',
19
+ blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
20
+ },
21
+ { includeHeader: false },
22
+ )
23
+ return surfaceDocToMessages(doc, { surfaceId, catalogId: SKOPON_CATALOG_ID }) as unknown as A2uiMessage[]
24
+ }
25
+
26
+ /** 与 SkoponA2uiStreamRenderer 在 messages 清空时的 reset 行为一致。 */
27
+ function resetStreamProcessor(): MessageProcessor<ReactComponentImplementation> {
28
+ return createProcessor()
29
+ }
30
+
31
+ describe('SkoponA2uiStreamRenderer stream lifecycle', () => {
32
+ it('processes incremental messages into surfaces', () => {
33
+ const surfaceId = 'stream-test'
34
+ const messages = buildStreamMessages(surfaceId)
35
+ const processor = createProcessor()
36
+ processor.processMessages(messages)
37
+ expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
38
+ })
39
+
40
+ it('clears surfaces when stream messages are reset to empty', () => {
41
+ const surfaceId = 'stream-clear'
42
+ const messages = buildStreamMessages(surfaceId)
43
+ let processor = createProcessor()
44
+ processor.processMessages(messages)
45
+ expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
46
+
47
+ processor = resetStreamProcessor()
48
+ expect(processor.model.surfacesMap.size).toBe(0)
49
+ })
50
+
51
+ it('rebuilds processor when message count shrinks', () => {
52
+ const surfaceId = 'stream-shrink'
53
+ const messages = buildStreamMessages(surfaceId)
54
+ const processor = createProcessor()
55
+ processor.processMessages(messages)
56
+ const countAfterFull = processor.model.surfacesMap.size
57
+
58
+ const rebuilt = createProcessor()
59
+ rebuilt.processMessages(messages.slice(0, 1))
60
+ expect(rebuilt.model.surfacesMap.size).toBeLessThanOrEqual(countAfterFull)
61
+ })
62
+ })
@@ -0,0 +1,79 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'
3
+
4
+ vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
6
+ return { ...actual, injectBasicCatalogStyles: vi.fn() }
7
+ })
8
+
9
+ vi.mock('@a2ui/react/styles', () => ({
10
+ injectStyles: vi.fn(),
11
+ removeStyles: vi.fn(),
12
+ }))
13
+
14
+ import { createElement } from 'react'
15
+ import { render, cleanup } from '@testing-library/react'
16
+ import type { A2uiMessage } from '@a2ui/web_core/v0_9'
17
+ import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
18
+ import { SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
19
+ import SkoponA2uiStreamRenderer from './SkoponA2uiStreamRenderer'
20
+
21
+ function buildStreamMessages(surfaceId: string): A2uiMessage[] {
22
+ const doc = blocksToA2ui(
23
+ {
24
+ title: '',
25
+ description: '',
26
+ blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
27
+ },
28
+ { includeHeader: false },
29
+ )
30
+ return surfaceDocToMessages(doc, { surfaceId, catalogId: SKOPON_CATALOG_ID }) as unknown as A2uiMessage[]
31
+ }
32
+
33
+ beforeAll(() => {
34
+ class ResizeObserverMock {
35
+ observe() {}
36
+ unobserve() {}
37
+ disconnect() {}
38
+ }
39
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
40
+ })
41
+
42
+ describe('SkoponA2uiStreamRenderer component', () => {
43
+ afterEach(() => {
44
+ cleanup()
45
+ })
46
+
47
+ it('mounts with empty messages without infinite update loop', () => {
48
+ expect(() =>
49
+ render(
50
+ createElement(SkoponA2uiStreamRenderer, {
51
+ messages: [],
52
+ emptyHint: createElement('span', null, 'stream-empty'),
53
+ }),
54
+ ),
55
+ ).not.toThrow()
56
+ })
57
+
58
+ it('reprocesses messages when surfaceId changes', () => {
59
+ const surfaceA = 'stream-a'
60
+ const surfaceB = 'stream-b'
61
+ const messages = buildStreamMessages(surfaceA)
62
+ const { rerender, getByText, queryByText } = render(
63
+ createElement(SkoponA2uiStreamRenderer, {
64
+ messages,
65
+ surfaceId: surfaceA,
66
+ }),
67
+ )
68
+ expect(getByText('姓名')).toBeTruthy()
69
+
70
+ rerender(
71
+ createElement(SkoponA2uiStreamRenderer, {
72
+ messages,
73
+ surfaceId: surfaceB,
74
+ }),
75
+ )
76
+ expect(getByText('姓名')).toBeTruthy()
77
+ expect(queryByText('stream-empty')).toBeNull()
78
+ })
79
+ })
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { MessageProcessor, type A2uiMessage } from '@a2ui/web_core/v0_9'
3
3
  import { injectBasicCatalogStyles } from '@a2ui/web_core/v0_9/basic_catalog'
4
4
  import { A2uiSurface, type ReactComponentImplementation } from '@a2ui/react/v0_9'
@@ -14,6 +14,10 @@ export interface SkoponA2uiStreamRendererProps {
14
14
  interactive?: boolean
15
15
  }
16
16
 
17
+ function createProcessor(): MessageProcessor<ReactComponentImplementation> {
18
+ return new MessageProcessor([buildSkoponCatalog()])
19
+ }
20
+
17
21
  export default function SkoponA2uiStreamRenderer({
18
22
  messages,
19
23
  surfaceId = 'skopon-form-stream',
@@ -25,20 +29,32 @@ export default function SkoponA2uiStreamRenderer({
25
29
  injectBasicCatalogStyles()
26
30
  }, [])
27
31
 
28
- const processorRef = useRef<MessageProcessor<ReactComponentImplementation> | null>(null)
32
+ const processorRef = useRef<MessageProcessor<ReactComponentImplementation>>(createProcessor())
29
33
  const processedCountRef = useRef(0)
34
+ const mountedRef = useRef(true)
35
+ const surfaceIdRef = useRef(surfaceId)
36
+
37
+ const [processor, setProcessor] = useState(() => processorRef.current)
38
+ const [surfaces, setSurfaces] = useState(() =>
39
+ Array.from(processorRef.current.model.surfacesMap.values()),
40
+ )
30
41
 
31
- const processor = useMemo(() => {
32
- const p = new MessageProcessor([buildSkoponCatalog()])
33
- processorRef.current = p
34
- processedCountRef.current = 0
35
- return p
36
- }, [surfaceId])
42
+ useEffect(() => {
43
+ mountedRef.current = true
44
+ return () => {
45
+ mountedRef.current = false
46
+ }
47
+ }, [])
37
48
 
38
- const [surfaces, setSurfaces] = useState(() => Array.from(processor.model.surfacesMap.values()))
49
+ useEffect(() => {
50
+ processorRef.current = processor
51
+ }, [processor])
39
52
 
40
53
  useEffect(() => {
41
- const sync = () => setSurfaces(Array.from(processor.model.surfacesMap.values()))
54
+ const sync = () => {
55
+ if (!mountedRef.current) return
56
+ setSurfaces(Array.from(processor.model.surfacesMap.values()))
57
+ }
42
58
  const createdSub = processor.onSurfaceCreated(sync)
43
59
  const deletedSub = processor.onSurfaceDeleted(sync)
44
60
  return () => {
@@ -48,13 +64,78 @@ export default function SkoponA2uiStreamRenderer({
48
64
  }, [processor])
49
65
 
50
66
  useEffect(() => {
51
- if (!Array.isArray(messages) || messages.length === 0) return
67
+ let cancelled = false
68
+ const surfaceIdChanged = surfaceIdRef.current !== surfaceId
69
+ surfaceIdRef.current = surfaceId
70
+
71
+ if (surfaceIdChanged) {
72
+ const next = createProcessor()
73
+ processorRef.current = next
74
+ processedCountRef.current = 0
75
+ if (Array.isArray(messages) && messages.length > 0) {
76
+ next.processMessages(messages)
77
+ processedCountRef.current = messages.length
78
+ if (!cancelled) {
79
+ setProcessor(next)
80
+ setSurfaces(Array.from(next.model.surfacesMap.values()))
81
+ }
82
+ } else if (!cancelled) {
83
+ setProcessor(next)
84
+ setSurfaces([])
85
+ }
86
+ return () => {
87
+ cancelled = true
88
+ }
89
+ }
90
+
91
+ if (!Array.isArray(messages) || messages.length === 0) {
92
+ if (processedCountRef.current > 0) {
93
+ const next = createProcessor()
94
+ processorRef.current = next
95
+ processedCountRef.current = 0
96
+ if (!cancelled) {
97
+ setProcessor(next)
98
+ setSurfaces([])
99
+ }
100
+ }
101
+ return () => {
102
+ cancelled = true
103
+ }
104
+ }
105
+
106
+ const activeProcessor = processorRef.current
107
+
108
+ if (messages.length < processedCountRef.current) {
109
+ const next = createProcessor()
110
+ processorRef.current = next
111
+ processedCountRef.current = 0
112
+ next.processMessages(messages)
113
+ processedCountRef.current = messages.length
114
+ if (!cancelled) {
115
+ setProcessor(next)
116
+ setSurfaces(Array.from(next.model.surfacesMap.values()))
117
+ }
118
+ return () => {
119
+ cancelled = true
120
+ }
121
+ }
122
+
52
123
  const pending = messages.slice(processedCountRef.current)
53
- if (pending.length === 0) return
54
- processor.processMessages(pending)
124
+ if (pending.length === 0) {
125
+ return () => {
126
+ cancelled = true
127
+ }
128
+ }
129
+ activeProcessor.processMessages(pending)
55
130
  processedCountRef.current = messages.length
56
- setSurfaces(Array.from(processor.model.surfacesMap.values()))
57
- }, [messages, processor])
131
+ if (!cancelled) {
132
+ setSurfaces(Array.from(activeProcessor.model.surfacesMap.values()))
133
+ }
134
+
135
+ return () => {
136
+ cancelled = true
137
+ }
138
+ }, [messages, surfaceId])
58
139
 
59
140
  if (surfaces.length === 0) return <>{emptyHint}</>
60
141
 
@@ -49,10 +49,9 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
49
49
  const fieldNamesRef = useRef(fieldNames)
50
50
  fieldNamesRef.current = fieldNames
51
51
 
52
- const surfaces = useMemo(() => {
52
+ const renderResult = useMemo(() => {
53
53
  if (isA2uiSurfaceEmpty(doc)) {
54
- processorRef.current = null
55
- return []
54
+ return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
56
55
  }
57
56
  try {
58
57
  const processor = new MessageProcessor([buildSkoponCatalog()])
@@ -61,15 +60,19 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
61
60
  catalogId: SKOPON_CATALOG_ID,
62
61
  }) as unknown as A2uiMessage[]
63
62
  processor.processMessages(messages)
64
- processorRef.current = processor
65
- return Array.from(processor.model.surfacesMap.values())
63
+ return {
64
+ surfaces: Array.from(processor.model.surfacesMap.values()),
65
+ processor,
66
+ }
66
67
  } catch (error) {
67
68
  console.error('[SkoponFormRenderer] 渲染 A2UI surface 失败', error)
68
- processorRef.current = null
69
- return []
69
+ return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
70
70
  }
71
71
  }, [doc, surfaceId])
72
72
 
73
+ const { surfaces, processor } = renderResult
74
+ processorRef.current = processor
75
+
73
76
  useImperativeHandle(
74
77
  ref,
75
78
  () => ({
package/src/index.ts CHANGED
@@ -41,8 +41,17 @@ export { normalizeFormDefinition, syncFormDefinition } from './adapter/formSchem
41
41
 
42
42
  export { extractSurfaceValues } from './adapter/extractSurfaceValues'
43
43
 
44
- export { buildCurlStatement } from './submit/buildCurlStatement'
45
- export { intersectPayloadWithForm, type PayloadFormIntersection } from './submit/intersectPayloadWithForm'
44
+ export { buildCurlStatement, buildAskUserCurlStatement, buildAskUserCurlBodyJson } from './submit/buildCurlStatement'
45
+ export {
46
+ extractExtraBlockValues,
47
+ getPayloadInputFieldNames,
48
+ getPayloadRenderableBlocks,
49
+ intersectPayloadBlocksWithForm,
50
+ parsePayloadBlocksJson,
51
+ payloadHasInputBlocks,
52
+ type PayloadBlocksDefinition,
53
+ type PayloadBlocksIntersection,
54
+ } from './submit/intersectPayloadBlocksWithForm'
46
55
  export {
47
56
  copyTextToClipboard,
48
57
  submitFormJson,
@@ -132,6 +132,11 @@
132
132
  min-width: 0;
133
133
  }
134
134
 
135
+ .ask-user-form-card-status {
136
+ display: flex;
137
+ justify-content: flex-end;
138
+ }
139
+
135
140
  .ask-user-form-actions {
136
141
  display: flex;
137
142
  justify-content: flex-end;