@skopon-cool/form-sdk 0.1.1 → 0.1.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.
Files changed (93) hide show
  1. package/README.md +49 -11
  2. package/dist/adapter/a2uiAdapter.d.ts +0 -1
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formFileAccept.d.ts.map +1 -1
  5. package/dist/adapter/formSchema.d.ts +1 -0
  6. package/dist/adapter/formSchema.d.ts.map +1 -1
  7. package/dist/blocks/case_singleselect/adapter.d.ts +10 -0
  8. package/dist/blocks/case_singleselect/adapter.d.ts.map +1 -0
  9. package/dist/blocks/case_singleselect/catalog.d.ts +2 -0
  10. package/dist/blocks/case_singleselect/catalog.d.ts.map +1 -0
  11. package/dist/blocks/case_singleselect/index.d.ts +3 -0
  12. package/dist/blocks/case_singleselect/index.d.ts.map +1 -0
  13. package/dist/blocks/registry.d.ts +8 -0
  14. package/dist/blocks/registry.d.ts.map +1 -0
  15. package/dist/blocks/resume_multiselect/adapter.d.ts +10 -0
  16. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -0
  17. package/dist/blocks/resume_multiselect/catalog.d.ts +2 -0
  18. package/dist/blocks/resume_multiselect/catalog.d.ts.map +1 -0
  19. package/dist/blocks/resume_multiselect/index.d.ts +3 -0
  20. package/dist/blocks/resume_multiselect/index.d.ts.map +1 -0
  21. package/dist/blocks/types.d.ts +27 -0
  22. package/dist/blocks/types.d.ts.map +1 -0
  23. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  24. package/dist/catalog/caseSearchContext.d.ts +28 -0
  25. package/dist/catalog/caseSearchContext.d.ts.map +1 -0
  26. package/dist/catalog/resumeSearchContext.d.ts +30 -0
  27. package/dist/catalog/resumeSearchContext.d.ts.map +1 -0
  28. package/dist/catalog/skoponCaseSelect.d.ts +2 -0
  29. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -0
  30. package/dist/catalog/skoponResumeSelect.d.ts +2 -0
  31. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -0
  32. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  33. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  34. package/dist/client/formClient.d.ts.map +1 -1
  35. package/dist/components/AskUserFormCard.d.ts +10 -2
  36. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  37. package/dist/components/SkoponA2uiStreamRenderer.d.ts +7 -1
  38. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  39. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
  40. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
  41. package/dist/components/SkoponFormRenderer.d.ts +6 -0
  42. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  43. package/dist/form-sdk.css +1 -1
  44. package/dist/index.d.ts +7 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1390 -710
  47. package/dist/submit/buildCurlStatement.d.ts +8 -0
  48. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  49. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
  50. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
  51. package/dist/submit/submitFormJson.d.ts.map +1 -1
  52. package/dist/types/index.d.ts +26 -2
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/package.json +11 -6
  55. package/src/adapter/a2uiAdapter.test.ts +116 -0
  56. package/src/adapter/a2uiAdapter.ts +48 -4
  57. package/src/adapter/formFileAccept.test.ts +53 -0
  58. package/src/adapter/formFileAccept.ts +11 -2
  59. package/src/adapter/formSchema.test.ts +35 -0
  60. package/src/adapter/formSchema.ts +70 -3
  61. package/src/blocks/case_singleselect/adapter.ts +74 -0
  62. package/src/blocks/case_singleselect/catalog.ts +1 -0
  63. package/src/blocks/case_singleselect/index.ts +14 -0
  64. package/src/blocks/registry.ts +34 -0
  65. package/src/blocks/resume_multiselect/adapter.ts +57 -0
  66. package/src/blocks/resume_multiselect/catalog.ts +1 -0
  67. package/src/blocks/resume_multiselect/index.ts +14 -0
  68. package/src/blocks/types.ts +34 -0
  69. package/src/catalog/a2uiCustomCatalog.tsx +34 -5
  70. package/src/catalog/caseSearchContext.tsx +46 -0
  71. package/src/catalog/resumeSearchContext.tsx +48 -0
  72. package/src/catalog/skoponCaseSelect.tsx +215 -0
  73. package/src/catalog/skoponResumeSelect.tsx +227 -0
  74. package/src/catalog/textFieldPreview.test.tsx +1 -1
  75. package/src/catalog/useSkoponBoundField.test.ts +62 -0
  76. package/src/catalog/useSkoponBoundField.ts +10 -1
  77. package/src/client/formClient.test.ts +83 -0
  78. package/src/client/formClient.ts +10 -2
  79. package/src/components/AskUserFormCard.tsx +146 -58
  80. package/src/components/SkoponA2uiStreamRenderer.test.ts +78 -0
  81. package/src/components/SkoponA2uiStreamRenderer.test.tsx +103 -0
  82. package/src/components/SkoponA2uiStreamRenderer.tsx +141 -23
  83. package/src/components/SkoponFormRenderer.tsx +42 -17
  84. package/src/index.ts +34 -2
  85. package/src/styles/index.css +65 -0
  86. package/src/submit/buildCurlStatement.ts +49 -0
  87. package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
  88. package/src/submit/submit.test.ts +170 -10
  89. package/src/submit/submitFormJson.ts +20 -1
  90. package/src/types/index.ts +30 -0
  91. package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
  92. package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
  93. package/src/submit/intersectPayloadWithForm.ts +0 -54
@@ -1,20 +1,48 @@
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'
15
+ import type { CaseSearchFn } from '../catalog/caseSearchContext'
16
+ import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
8
17
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
9
18
  import CurlSubmitBlock from './CurlSubmitBlock'
10
19
 
20
+ function formatSubmitErrorMessage(status: number, body: unknown): string {
21
+ if (body && typeof body === 'object' && body !== null && 'message' in body) {
22
+ const msg = (body as { message: unknown }).message
23
+ if (typeof msg === 'string' && msg.trim()) {
24
+ return `提交失败 (${status}): ${msg.trim()}`
25
+ }
26
+ }
27
+ if (typeof body === 'string' && body.trim()) {
28
+ return `提交失败 (${status}): ${body.trim()}`
29
+ }
30
+ return `提交失败 (${status})`
31
+ }
32
+
11
33
  export interface AskUserFormCardProps {
12
34
  payload: unknown
13
- formUniqueId: string
35
+ /** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
36
+ formUniqueId?: string | null
14
37
  callbackUrl?: string | null
15
38
  submitMode?: SubmitMode
39
+ /** 需 useCallback 稳定引用,避免重复拉取 */
16
40
  fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
17
41
  onNotify?: (type: 'success' | 'error', message: string) => void
42
+ /** 简历多选组件:注入搜索函数 */
43
+ resumeSearch?: ResumeSearchFn | null
44
+ /** 案例单选组件:注入搜索函数 */
45
+ caseSearch?: CaseSearchFn | null
18
46
  }
19
47
 
20
48
  export default function AskUserFormCard({
@@ -24,26 +52,52 @@ export default function AskUserFormCard({
24
52
  submitMode = 'curl',
25
53
  fetchFormDetail,
26
54
  onNotify,
55
+ resumeSearch = null,
56
+ caseSearch = null,
27
57
  }: AskUserFormCardProps) {
28
58
  const [formDef, setFormDef] = useState<FormSchema | null>(null)
29
59
  const [formDisabled, setFormDisabled] = useState(false)
30
60
  const [loading, setLoading] = useState(true)
61
+ const [submitting, setSubmitting] = useState(false)
31
62
  const rendererRef = useRef<SkoponFormRendererRef>(null)
63
+ const mountedRef = useRef(true)
64
+ const onNotifyRef = useRef(onNotify)
65
+ onNotifyRef.current = onNotify
66
+
67
+ const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
68
+
69
+ useEffect(() => {
70
+ mountedRef.current = true
71
+ return () => {
72
+ mountedRef.current = false
73
+ }
74
+ }, [])
32
75
 
33
76
  useEffect(() => {
34
77
  let cancelled = false
78
+ if (!resolvedFormUniqueId) {
79
+ setLoading(false)
80
+ setFormDisabled(false)
81
+ setFormDef(null)
82
+ return () => {
83
+ cancelled = true
84
+ }
85
+ }
86
+
35
87
  setLoading(true)
36
88
  setFormDisabled(false)
37
- fetchFormDetail({ formUniqueId })
89
+ fetchFormDetail({ formUniqueId: resolvedFormUniqueId })
38
90
  .then((detail) => {
39
91
  if (cancelled) return
40
92
  setFormDisabled(Boolean(detail.disabled))
41
- setFormDef(detail.disabled ? null : detail.formDefinition ?? null)
93
+ setFormDef(detail.formDefinition ?? null)
42
94
  })
43
- .catch(() => {
95
+ .catch((error) => {
44
96
  if (!cancelled) {
45
97
  setFormDisabled(false)
46
98
  setFormDef(null)
99
+ const detail = error instanceof Error ? error.message : '获取表单定义失败'
100
+ onNotifyRef.current?.('error', detail)
47
101
  }
48
102
  })
49
103
  .finally(() => {
@@ -52,25 +106,49 @@ export default function AskUserFormCard({
52
106
  return () => {
53
107
  cancelled = true
54
108
  }
55
- }, [formUniqueId, fetchFormDetail])
109
+ }, [resolvedFormUniqueId, fetchFormDetail])
56
110
 
57
- const { matchedBlocks, remainderPayload } = useMemo(
58
- () => intersectPayloadWithForm(payload, formDef ?? undefined),
59
- [payload, formDef],
60
- )
111
+ const payloadDef = useMemo(() => parsePayloadBlocksJson(payload), [payload])
112
+
113
+ const intersection = useMemo(() => {
114
+ if (!payloadDef) return null
115
+ return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined)
116
+ }, [payloadDef, formDef])
117
+
118
+ const matchedBlocks = intersection?.matchedBlocks ?? []
119
+ const usePayloadFallback =
120
+ Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
61
121
 
62
- const fieldNames = useMemo(
63
- () =>
64
- matchedBlocks
65
- .map((block) => block.name?.trim())
66
- .filter((name): name is string => Boolean(name)),
67
- [matchedBlocks],
122
+ const renderBlocks = useMemo(() => {
123
+ if (intersection && intersection.renderBlocks.length > 0) {
124
+ return intersection.renderBlocks
125
+ }
126
+ if (usePayloadFallback && payloadDef) return getPayloadRenderableBlocks(payloadDef)
127
+ return []
128
+ }, [intersection, usePayloadFallback, payloadDef])
129
+
130
+ const extraValues = useMemo(
131
+ () => (usePayloadFallback ? {} : extractExtraBlockValues(intersection?.extraBlocks ?? [])),
132
+ [usePayloadFallback, intersection],
68
133
  )
69
134
 
135
+ const fieldNames = useMemo(() => {
136
+ if (usePayloadFallback && payloadDef) return getPayloadInputFieldNames(payloadDef)
137
+ return renderBlocks
138
+ .filter((block) => block.name?.trim())
139
+ .map((block) => block.name!.trim())
140
+ }, [usePayloadFallback, payloadDef, renderBlocks])
141
+
70
142
  const surfaceDoc = useMemo(() => {
71
- if (!formDef || matchedBlocks.length === 0) return null
72
- return buildAskUserSurface(formDef, matchedBlocks)
73
- }, [formDef, matchedBlocks])
143
+ if (renderBlocks.length === 0) return null
144
+ const title = intersection?.title ?? payloadDef?.title ?? ''
145
+ const description = intersection?.description ?? payloadDef?.description ?? ''
146
+ return buildAskUserSurface({ title, description }, renderBlocks)
147
+ }, [intersection, payloadDef, renderBlocks])
148
+
149
+ const surfaceId = resolvedFormUniqueId
150
+ ? `ask-user-${resolvedFormUniqueId}`
151
+ : 'ask-user-payload'
74
152
 
75
153
  if (loading) {
76
154
  return (
@@ -80,76 +158,86 @@ export default function AskUserFormCard({
80
158
  )
81
159
  }
82
160
 
83
- if (matchedBlocks.length === 0) {
161
+ if (!payloadDef || renderBlocks.length === 0) {
84
162
  return (
85
163
  <CurlSubmitBlock
86
164
  payload={payload}
87
165
  callbackUrl={callbackUrl}
88
- unpublishedFormId={formDisabled ? formUniqueId : undefined}
166
+ unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
89
167
  onNotify={onNotify}
90
168
  />
91
169
  )
92
170
  }
93
171
 
94
- const hasRemainder = Object.keys(remainderPayload).length > 0
95
-
96
172
  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)
173
+ if (submitting) return
174
+ setSubmitting(true)
175
+ try {
176
+ const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
177
+ const submitBody = { ...cardValues, ...extraValues }
178
+
179
+ if (submitMode === 'post') {
180
+ const url = (callbackUrl ?? '').trim()
181
+ if (!url) {
182
+ onNotifyRef.current?.('error', 'callback_url 为空,无法提交')
183
+ return
184
+ }
185
+ const result = await submitFormJson(url, submitBody)
186
+ if (!mountedRef.current) return
106
187
  if (result.ok) {
107
- onNotify?.('success', '提交成功')
188
+ onNotifyRef.current?.('success', '提交成功')
108
189
  } else {
109
- onNotify?.('error', `提交失败 (${result.status})`)
190
+ onNotifyRef.current?.('error', formatSubmitErrorMessage(result.status, result.body))
110
191
  }
111
- } catch (error) {
112
- const detail = error instanceof Error ? error.message : '提交失败'
113
- onNotify?.('error', detail)
192
+ return
114
193
  }
115
- return
116
- }
117
194
 
118
- try {
119
- await copyTextToClipboard(buildCurlStatement(values, callbackUrl))
120
- onNotify?.('success', '已复制 curl 到剪贴板')
121
- } catch {
122
- onNotify?.('error', '复制失败')
195
+ await copyTextToClipboard(
196
+ buildAskUserCurlStatement({ cardValues, extraValues, callbackUrl }),
197
+ )
198
+ if (!mountedRef.current) return
199
+ onNotifyRef.current?.('success', '已复制 curl 到剪贴板')
200
+ } catch (error) {
201
+ if (!mountedRef.current) return
202
+ const detail = error instanceof Error ? error.message : '复制失败'
203
+ onNotifyRef.current?.('error', detail)
204
+ } finally {
205
+ if (mountedRef.current) setSubmitting(false)
123
206
  }
124
207
  }
125
208
 
126
209
  return (
127
210
  <div className="ask-user-form-card">
211
+ {formDisabled && resolvedFormUniqueId ? (
212
+ <div className="ask-user-form-card-status">
213
+ <Tooltip title={resolvedFormUniqueId}>
214
+ <Tag className="ask-user-curl-unpublished-tag">卡片 ID 未发布</Tag>
215
+ </Tooltip>
216
+ </div>
217
+ ) : null}
128
218
  <SkoponFormRenderer
129
219
  ref={rendererRef}
130
220
  doc={surfaceDoc}
131
- surfaceId={`ask-user-${formUniqueId}`}
221
+ surfaceId={surfaceId}
132
222
  fieldNames={fieldNames}
133
223
  interactive
224
+ resumeSearch={resumeSearch}
225
+ caseSearch={caseSearch}
134
226
  />
135
227
 
136
228
  <div className="ask-user-form-actions">
137
229
  <Tooltip title={submitMode === 'curl' ? '点击复制 curl' : '提交 JSON 到 callback_url'}>
138
- <Button type="primary" size="small" onClick={() => void handleSubmit()}>
230
+ <Button
231
+ type="primary"
232
+ size="small"
233
+ loading={submitting}
234
+ disabled={submitting}
235
+ onClick={() => void handleSubmit()}
236
+ >
139
237
  提交
140
238
  </Button>
141
239
  </Tooltip>
142
240
  </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
241
  </div>
154
242
  )
155
243
  }
@@ -0,0 +1,78 @@
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, label = '姓名'): 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
+
63
+ it('rebuilds processor when message count is unchanged but content replaced', () => {
64
+ const surfaceId = 'stream-same-len'
65
+ const messagesA = buildStreamMessages(surfaceId, '姓名')
66
+ const messagesB = buildStreamMessages(surfaceId, '昵称')
67
+ expect(messagesA.length).toBe(messagesB.length)
68
+
69
+ const processor = createProcessor()
70
+ processor.processMessages(messagesA)
71
+ expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
72
+
73
+ const rebuilt = createProcessor()
74
+ rebuilt.processMessages(messagesB)
75
+ expect(rebuilt.model.surfacesMap.size).toBeGreaterThan(0)
76
+ expect(JSON.stringify(messagesA)).not.toBe(JSON.stringify(messagesB))
77
+ })
78
+ })
@@ -0,0 +1,103 @@
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, label = '姓名'): 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
+
80
+ it('reprocesses messages when stream content is replaced at same length', () => {
81
+ const surfaceId = 'stream-replace'
82
+ const messagesA = buildStreamMessages(surfaceId, '姓名')
83
+ const messagesB = buildStreamMessages(surfaceId, '昵称')
84
+ expect(messagesA.length).toBe(messagesB.length)
85
+
86
+ const { rerender, getByText, queryByText } = render(
87
+ createElement(SkoponA2uiStreamRenderer, {
88
+ messages: messagesA,
89
+ surfaceId,
90
+ }),
91
+ )
92
+ expect(getByText('姓名')).toBeTruthy()
93
+
94
+ rerender(
95
+ createElement(SkoponA2uiStreamRenderer, {
96
+ messages: messagesB,
97
+ surfaceId,
98
+ }),
99
+ )
100
+ expect(getByText('昵称')).toBeTruthy()
101
+ expect(queryByText('姓名')).toBeNull()
102
+ })
103
+ })
@@ -1,10 +1,12 @@
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'
5
5
  import { injectStyles } from '@a2ui/react/styles'
6
6
  import { buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
7
7
  import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
8
+ import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
9
+ import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
8
10
 
9
11
  export interface SkoponA2uiStreamRendererProps {
10
12
  /** 增量追加的 A2UI v0.9 消息 */
@@ -12,6 +14,14 @@ export interface SkoponA2uiStreamRendererProps {
12
14
  surfaceId?: string
13
15
  emptyHint?: ReactNode
14
16
  interactive?: boolean
17
+ /** 简历多选组件:注入搜索函数 */
18
+ resumeSearch?: ResumeSearchFn | null
19
+ /** 案例单选组件:注入搜索函数 */
20
+ caseSearch?: CaseSearchFn | null
21
+ }
22
+
23
+ function createProcessor(): MessageProcessor<ReactComponentImplementation> {
24
+ return new MessageProcessor([buildSkoponCatalog()])
15
25
  }
16
26
 
17
27
  export default function SkoponA2uiStreamRenderer({
@@ -19,26 +29,66 @@ export default function SkoponA2uiStreamRenderer({
19
29
  surfaceId = 'skopon-form-stream',
20
30
  emptyHint = null,
21
31
  interactive = true,
32
+ resumeSearch = null,
33
+ caseSearch = null,
22
34
  }: SkoponA2uiStreamRendererProps) {
23
35
  useEffect(() => {
24
36
  injectStyles()
25
37
  injectBasicCatalogStyles()
26
38
  }, [])
27
39
 
28
- const processorRef = useRef<MessageProcessor<ReactComponentImplementation> | null>(null)
40
+ const processorRef = useRef<MessageProcessor<ReactComponentImplementation>>(createProcessor())
29
41
  const processedCountRef = useRef(0)
42
+ const lastProcessedMessagesRef = useRef('')
43
+ const mountedRef = useRef(true)
44
+ const surfaceIdRef = useRef(surfaceId)
45
+
46
+ function commitProcessor(
47
+ next: MessageProcessor<ReactComponentImplementation>,
48
+ processedMessages: A2uiMessage[],
49
+ ) {
50
+ processorRef.current = next
51
+ processedCountRef.current = processedMessages.length
52
+ lastProcessedMessagesRef.current = JSON.stringify(processedMessages)
53
+ if (!mountedRef.current) return
54
+ setProcessor(next)
55
+ setSurfaces(Array.from(next.model.surfacesMap.values()))
56
+ }
57
+
58
+ function processedPrefixChanged(messages: A2uiMessage[], prefixLen: number): boolean {
59
+ if (prefixLen <= 0) return false
60
+ const newPrefix = JSON.stringify(messages.slice(0, prefixLen))
61
+ const stored = lastProcessedMessagesRef.current
62
+ if (!stored) return true
63
+ try {
64
+ const parsed = JSON.parse(stored) as A2uiMessage[]
65
+ return JSON.stringify(parsed.slice(0, prefixLen)) !== newPrefix
66
+ } catch {
67
+ return true
68
+ }
69
+ }
70
+
71
+ const [processor, setProcessor] = useState(() => processorRef.current)
72
+ const [surfaces, setSurfaces] = useState(() =>
73
+ Array.from(processorRef.current.model.surfacesMap.values()),
74
+ )
30
75
 
31
- const processor = useMemo(() => {
32
- const p = new MessageProcessor([buildSkoponCatalog()])
33
- processorRef.current = p
34
- processedCountRef.current = 0
35
- return p
36
- }, [surfaceId])
76
+ useEffect(() => {
77
+ mountedRef.current = true
78
+ return () => {
79
+ mountedRef.current = false
80
+ }
81
+ }, [])
37
82
 
38
- const [surfaces, setSurfaces] = useState(() => Array.from(processor.model.surfacesMap.values()))
83
+ useEffect(() => {
84
+ processorRef.current = processor
85
+ }, [processor])
39
86
 
40
87
  useEffect(() => {
41
- const sync = () => setSurfaces(Array.from(processor.model.surfacesMap.values()))
88
+ const sync = () => {
89
+ if (!mountedRef.current) return
90
+ setSurfaces(Array.from(processor.model.surfacesMap.values()))
91
+ }
42
92
  const createdSub = processor.onSurfaceCreated(sync)
43
93
  const deletedSub = processor.onSurfaceDeleted(sync)
44
94
  return () => {
@@ -48,23 +98,91 @@ export default function SkoponA2uiStreamRenderer({
48
98
  }, [processor])
49
99
 
50
100
  useEffect(() => {
51
- if (!Array.isArray(messages) || messages.length === 0) return
52
- const pending = messages.slice(processedCountRef.current)
53
- if (pending.length === 0) return
54
- processor.processMessages(pending)
101
+ let cancelled = false
102
+ const surfaceIdChanged = surfaceIdRef.current !== surfaceId
103
+ surfaceIdRef.current = surfaceId
104
+
105
+ if (surfaceIdChanged) {
106
+ const next = createProcessor()
107
+ if (Array.isArray(messages) && messages.length > 0) {
108
+ next.processMessages(messages)
109
+ if (!cancelled) commitProcessor(next, messages)
110
+ } else {
111
+ processorRef.current = next
112
+ processedCountRef.current = 0
113
+ lastProcessedMessagesRef.current = ''
114
+ if (!cancelled) {
115
+ setProcessor(next)
116
+ setSurfaces([])
117
+ }
118
+ }
119
+ return () => {
120
+ cancelled = true
121
+ }
122
+ }
123
+
124
+ if (!Array.isArray(messages) || messages.length === 0) {
125
+ if (processedCountRef.current > 0) {
126
+ const next = createProcessor()
127
+ processorRef.current = next
128
+ processedCountRef.current = 0
129
+ lastProcessedMessagesRef.current = ''
130
+ if (!cancelled) {
131
+ setProcessor(next)
132
+ setSurfaces([])
133
+ }
134
+ }
135
+ return () => {
136
+ cancelled = true
137
+ }
138
+ }
139
+
140
+ const activeProcessor = processorRef.current
141
+ const processedCount = processedCountRef.current
142
+
143
+ if (
144
+ messages.length < processedCount ||
145
+ processedPrefixChanged(messages, processedCount)
146
+ ) {
147
+ const next = createProcessor()
148
+ next.processMessages(messages)
149
+ if (!cancelled) commitProcessor(next, messages)
150
+ return () => {
151
+ cancelled = true
152
+ }
153
+ }
154
+
155
+ const pending = messages.slice(processedCount)
156
+ if (pending.length === 0) {
157
+ return () => {
158
+ cancelled = true
159
+ }
160
+ }
161
+ activeProcessor.processMessages(pending)
55
162
  processedCountRef.current = messages.length
56
- setSurfaces(Array.from(processor.model.surfacesMap.values()))
57
- }, [messages, processor])
163
+ lastProcessedMessagesRef.current = JSON.stringify(messages)
164
+ if (!cancelled) {
165
+ setSurfaces(Array.from(activeProcessor.model.surfacesMap.values()))
166
+ }
167
+
168
+ return () => {
169
+ cancelled = true
170
+ }
171
+ }, [messages, surfaceId])
58
172
 
59
173
  if (surfaces.length === 0) return <>{emptyHint}</>
60
174
 
61
175
  return (
62
- <A2uiPreviewModeProvider interactive={interactive}>
63
- <div className="a2ui-surface a2ui-container">
64
- {surfaces.map((surface) => (
65
- <A2uiSurface key={surface.id} surface={surface} />
66
- ))}
67
- </div>
68
- </A2uiPreviewModeProvider>
176
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
177
+ <CaseSearchProvider caseSearch={caseSearch}>
178
+ <A2uiPreviewModeProvider interactive={interactive}>
179
+ <div className="a2ui-surface a2ui-container">
180
+ {surfaces.map((surface) => (
181
+ <A2uiSurface key={surface.id} surface={surface} />
182
+ ))}
183
+ </div>
184
+ </A2uiPreviewModeProvider>
185
+ </CaseSearchProvider>
186
+ </ResumeSearchProvider>
69
187
  )
70
188
  }