@skopon-cool/form-sdk 0.1.3 → 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 (66) hide show
  1. package/README.md +2 -0
  2. package/dist/adapter/a2uiAdapter.d.ts +0 -1
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts.map +1 -1
  5. package/dist/blocks/case_singleselect/adapter.d.ts +10 -0
  6. package/dist/blocks/case_singleselect/adapter.d.ts.map +1 -0
  7. package/dist/blocks/case_singleselect/catalog.d.ts +2 -0
  8. package/dist/blocks/case_singleselect/catalog.d.ts.map +1 -0
  9. package/dist/blocks/case_singleselect/index.d.ts +3 -0
  10. package/dist/blocks/case_singleselect/index.d.ts.map +1 -0
  11. package/dist/blocks/registry.d.ts +8 -0
  12. package/dist/blocks/registry.d.ts.map +1 -0
  13. package/dist/blocks/resume_multiselect/adapter.d.ts +10 -0
  14. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -0
  15. package/dist/blocks/resume_multiselect/catalog.d.ts +2 -0
  16. package/dist/blocks/resume_multiselect/catalog.d.ts.map +1 -0
  17. package/dist/blocks/resume_multiselect/index.d.ts +3 -0
  18. package/dist/blocks/resume_multiselect/index.d.ts.map +1 -0
  19. package/dist/blocks/types.d.ts +27 -0
  20. package/dist/blocks/types.d.ts.map +1 -0
  21. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  22. package/dist/catalog/caseSearchContext.d.ts +28 -0
  23. package/dist/catalog/caseSearchContext.d.ts.map +1 -0
  24. package/dist/catalog/resumeSearchContext.d.ts +30 -0
  25. package/dist/catalog/resumeSearchContext.d.ts.map +1 -0
  26. package/dist/catalog/skoponCaseSelect.d.ts +2 -0
  27. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -0
  28. package/dist/catalog/skoponResumeSelect.d.ts +2 -0
  29. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -0
  30. package/dist/components/AskUserFormCard.d.ts +7 -1
  31. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  32. package/dist/components/SkoponA2uiStreamRenderer.d.ts +7 -1
  33. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  34. package/dist/components/SkoponFormRenderer.d.ts +6 -0
  35. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  36. package/dist/form-sdk.css +1 -1
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1290 -845
  40. package/dist/types/index.d.ts +26 -2
  41. package/dist/types/index.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/adapter/a2uiAdapter.test.ts +48 -0
  44. package/src/adapter/a2uiAdapter.ts +41 -1
  45. package/src/adapter/formSchema.ts +65 -2
  46. package/src/blocks/case_singleselect/adapter.ts +74 -0
  47. package/src/blocks/case_singleselect/catalog.ts +1 -0
  48. package/src/blocks/case_singleselect/index.ts +14 -0
  49. package/src/blocks/registry.ts +34 -0
  50. package/src/blocks/resume_multiselect/adapter.ts +57 -0
  51. package/src/blocks/resume_multiselect/catalog.ts +1 -0
  52. package/src/blocks/resume_multiselect/index.ts +14 -0
  53. package/src/blocks/types.ts +34 -0
  54. package/src/catalog/a2uiCustomCatalog.tsx +6 -0
  55. package/src/catalog/caseSearchContext.tsx +46 -0
  56. package/src/catalog/resumeSearchContext.tsx +48 -0
  57. package/src/catalog/skoponCaseSelect.tsx +215 -0
  58. package/src/catalog/skoponResumeSelect.tsx +227 -0
  59. package/src/components/AskUserFormCard.tsx +10 -0
  60. package/src/components/SkoponA2uiStreamRenderer.test.ts +18 -2
  61. package/src/components/SkoponA2uiStreamRenderer.test.tsx +26 -2
  62. package/src/components/SkoponA2uiStreamRenderer.tsx +60 -23
  63. package/src/components/SkoponFormRenderer.tsx +32 -10
  64. package/src/index.ts +23 -0
  65. package/src/styles/index.css +60 -0
  66. package/src/types/index.ts +30 -0
@@ -0,0 +1,48 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { FormResumeFilter } from '../types/index'
3
+
4
+ export interface ResumeSearchItem {
5
+ resumeUniqueId: string
6
+ resumeId?: number
7
+ agentId?: number
8
+ name: string
9
+ avatarUrl?: string | null
10
+ description?: string
11
+ satisfaction?: number
12
+ published?: number
13
+ worksCount?: number
14
+ }
15
+
16
+ export interface ResumeSearchParams {
17
+ filter: FormResumeFilter
18
+ page: number
19
+ }
20
+
21
+ export interface ResumeSearchResult {
22
+ list: ResumeSearchItem[]
23
+ total: number
24
+ page: number
25
+ pageSize: number
26
+ }
27
+
28
+ export type ResumeSearchFn = (params: ResumeSearchParams) => Promise<ResumeSearchResult>
29
+
30
+ const ResumeSearchContext = createContext<ResumeSearchFn | null>(null)
31
+
32
+ export function ResumeSearchProvider({
33
+ resumeSearch,
34
+ children,
35
+ }: {
36
+ resumeSearch: ResumeSearchFn | null | undefined
37
+ children: ReactNode
38
+ }) {
39
+ return (
40
+ <ResumeSearchContext.Provider value={resumeSearch ?? null}>
41
+ {children}
42
+ </ResumeSearchContext.Provider>
43
+ )
44
+ }
45
+
46
+ export function useResumeSearch(): ResumeSearchFn | null {
47
+ return useContext(ResumeSearchContext)
48
+ }
@@ -0,0 +1,215 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Button, Spin, Typography } from 'antd'
3
+ import { FileTextOutlined, ReloadOutlined } from '@ant-design/icons'
4
+ import type { ComponentContext } from '@a2ui/web_core/v0_9'
5
+ import { createBinderlessComponentImplementation } from '@a2ui/react/v0_9'
6
+ import { z } from 'zod'
7
+ import type { FormCaseFilter } from '../types/index'
8
+ import { useA2uiPreviewMode } from './a2uiPreviewContext'
9
+ import { asOptionalString, useSkoponBoundField } from './useSkoponBoundField'
10
+ import { type CaseSearchItem, useCaseSearch } from './caseSearchContext'
11
+
12
+ function readString(value: unknown): string {
13
+ return typeof value === 'string' ? value : ''
14
+ }
15
+
16
+ function readCaseFilter(raw: unknown): FormCaseFilter {
17
+ if (!raw || typeof raw !== 'object') {
18
+ return { pageSize: 20 }
19
+ }
20
+ const rec = raw as Record<string, unknown>
21
+ const flowIdRaw = typeof rec.flowId === 'number' ? rec.flowId : Number(rec.flowId)
22
+ const flowId =
23
+ Number.isFinite(flowIdRaw) && flowIdRaw > 0 ? Math.floor(flowIdRaw) : undefined
24
+ const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
25
+ const pageSize =
26
+ Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
27
+ ? Math.floor(pageSizeRaw)
28
+ : 20
29
+ return { flowId, pageSize }
30
+ }
31
+
32
+ function filterKey(filter: FormCaseFilter): string {
33
+ return JSON.stringify(filter)
34
+ }
35
+
36
+ const SkoponCaseSelectApi = {
37
+ name: 'SkoponCaseSelect',
38
+ schema: z
39
+ .object({
40
+ label: z.any().optional(),
41
+ placeholder: z.any().optional(),
42
+ help: z.any().optional(),
43
+ enableRefresh: z.any().optional(),
44
+ caseFilter: z.any().optional(),
45
+ value: z.any().optional(),
46
+ })
47
+ .passthrough(),
48
+ }
49
+
50
+ function CaseCard({
51
+ item,
52
+ selected,
53
+ disabled,
54
+ onToggle,
55
+ }: {
56
+ item: CaseSearchItem
57
+ selected: boolean
58
+ disabled: boolean
59
+ onToggle: () => void
60
+ }) {
61
+ return (
62
+ <button
63
+ type="button"
64
+ className={`skopon-resume-select-card${selected ? ' skopon-resume-select-card--selected' : ''}`}
65
+ disabled={disabled}
66
+ onClick={onToggle}
67
+ >
68
+ <div className="skopon-resume-select-card-avatar">
69
+ <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #1677ff)' }} />
70
+ </div>
71
+ <div className="skopon-resume-select-card-body">
72
+ <div className="skopon-resume-select-card-name">{item.name}</div>
73
+ {item.description ? (
74
+ <div className="skopon-resume-select-card-desc">{item.description}</div>
75
+ ) : null}
76
+ <div className="skopon-resume-select-card-meta">
77
+ {item.caseUniqueId}
78
+ {item.platform ? ` · ${item.platform}` : ''}
79
+ {item.link ? ` · ${item.link}` : ''}
80
+ </div>
81
+ </div>
82
+ </button>
83
+ )
84
+ }
85
+
86
+ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
87
+ const { interactive } = useA2uiPreviewMode()
88
+ const caseSearch = useCaseSearch()
89
+ const { value, setValue } = useSkoponBoundField(context)
90
+ const props = context.componentModel.properties as Record<string, unknown>
91
+
92
+ const label = readString(props.label)
93
+ const placeholder = readString(props.placeholder)
94
+ const help = readString(props.help)
95
+ const enableRefresh = props.enableRefresh !== false
96
+ const caseFilter = useMemo(() => readCaseFilter(props.caseFilter), [props.caseFilter])
97
+ const filterSig = filterKey(caseFilter)
98
+
99
+ const [page, setPage] = useState(1)
100
+ const [loading, setLoading] = useState(false)
101
+ const [fetchError, setFetchError] = useState('')
102
+ const [items, setItems] = useState<CaseSearchItem[]>([])
103
+ const [total, setTotal] = useState(0)
104
+ const [pageSize, setPageSize] = useState(caseFilter.pageSize ?? 20)
105
+ const requestSeqRef = useRef(0)
106
+
107
+ const selectedId = asOptionalString(value) ?? ''
108
+
109
+ const emptyHint = useMemo(() => {
110
+ if (!caseSearch) return '未配置案例搜索服务'
111
+ if (!caseFilter.flowId) return '未配置 Flow 筛选条件'
112
+ if (fetchError) return fetchError
113
+ return placeholder || '暂无匹配的案例'
114
+ }, [caseSearch, caseFilter.flowId, fetchError, placeholder])
115
+
116
+ const fetchPage = useCallback(
117
+ async (targetPage: number) => {
118
+ const filter = readCaseFilter(JSON.parse(filterSig) as FormCaseFilter)
119
+ const seq = ++requestSeqRef.current
120
+ if (!caseSearch || !filter.flowId) {
121
+ setItems([])
122
+ setTotal(0)
123
+ setFetchError('')
124
+ return
125
+ }
126
+ setLoading(true)
127
+ setFetchError('')
128
+ try {
129
+ const result = await caseSearch({ filter, page: targetPage })
130
+ if (seq !== requestSeqRef.current) return
131
+ setItems(result.list)
132
+ setTotal(result.total)
133
+ setPage(result.page)
134
+ setPageSize(result.pageSize)
135
+ } catch (err) {
136
+ if (seq !== requestSeqRef.current) return
137
+ setItems([])
138
+ setTotal(0)
139
+ setFetchError(err instanceof Error ? err.message : '加载案例列表失败')
140
+ } finally {
141
+ if (seq === requestSeqRef.current) setLoading(false)
142
+ }
143
+ },
144
+ [caseSearch, filterSig],
145
+ )
146
+
147
+ useEffect(() => {
148
+ setPage(1)
149
+ void fetchPage(1)
150
+ }, [filterSig, fetchPage])
151
+
152
+ const toggleSelect = (caseUniqueId: string) => {
153
+ if (!interactive) return
154
+ setValue(selectedId === caseUniqueId ? '' : caseUniqueId)
155
+ }
156
+
157
+ const handleRefresh = () => {
158
+ const filter = readCaseFilter(JSON.parse(filterSig) as FormCaseFilter)
159
+ const effectivePageSize = pageSize || filter.pageSize || 20
160
+ const totalPages = Math.max(1, Math.ceil(total / effectivePageSize))
161
+ const nextPage = page >= totalPages ? 1 : page + 1
162
+ void fetchPage(nextPage)
163
+ }
164
+
165
+ return (
166
+ <div className="form-block-preview skopon-resume-select">
167
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
168
+ {loading && items.length === 0 ? (
169
+ <div className="skopon-resume-select-status">
170
+ <Spin size="small" /> 加载案例…
171
+ </div>
172
+ ) : null}
173
+ {!loading && items.length === 0 ? (
174
+ <div className="skopon-resume-select-empty">{emptyHint}</div>
175
+ ) : null}
176
+ {items.length > 0 ? (
177
+ <div className="skopon-resume-select-list">
178
+ {items.map((item) => (
179
+ <CaseCard
180
+ key={item.caseUniqueId}
181
+ item={item}
182
+ selected={selectedId === item.caseUniqueId}
183
+ disabled={!interactive}
184
+ onToggle={() => toggleSelect(item.caseUniqueId)}
185
+ />
186
+ ))}
187
+ </div>
188
+ ) : null}
189
+ {enableRefresh && caseSearch && caseFilter.flowId ? (
190
+ <div className="skopon-resume-select-actions">
191
+ <Button
192
+ type="default"
193
+ size="small"
194
+ icon={<ReloadOutlined />}
195
+ loading={loading}
196
+ disabled={!interactive}
197
+ onClick={handleRefresh}
198
+ >
199
+ 换一批
200
+ </Button>
201
+ </div>
202
+ ) : null}
203
+ {help ? (
204
+ <Typography.Text type="secondary" className="form-block-preview-help">
205
+ {help}
206
+ </Typography.Text>
207
+ ) : null}
208
+ </div>
209
+ )
210
+ }
211
+
212
+ export const SkoponCaseSelectImpl = createBinderlessComponentImplementation(
213
+ SkoponCaseSelectApi,
214
+ SkoponCaseSelectPreview,
215
+ )
@@ -0,0 +1,227 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Avatar, Button, Spin, Typography } from 'antd'
3
+ import { ReloadOutlined, UserOutlined } from '@ant-design/icons'
4
+ import type { ComponentContext } from '@a2ui/web_core/v0_9'
5
+ import { createBinderlessComponentImplementation } from '@a2ui/react/v0_9'
6
+ import { z } from 'zod'
7
+ import type { FormResumeFilter } from '../types/index'
8
+ import { useA2uiPreviewMode } from './a2uiPreviewContext'
9
+ import { asStringArray, useSkoponBoundField } from './useSkoponBoundField'
10
+ import {
11
+ type ResumeSearchItem,
12
+ useResumeSearch,
13
+ } from './resumeSearchContext'
14
+
15
+ function readString(value: unknown): string {
16
+ return typeof value === 'string' ? value : ''
17
+ }
18
+
19
+ function readResumeFilter(raw: unknown): FormResumeFilter {
20
+ if (!raw || typeof raw !== 'object') {
21
+ return { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 }
22
+ }
23
+ const rec = raw as Record<string, unknown>
24
+ const names = Array.isArray(rec.names) ? rec.names.map(String).filter(Boolean) : []
25
+ const agentUniqueIds = Array.isArray(rec.agentUniqueIds)
26
+ ? rec.agentUniqueIds.map(String).filter(Boolean)
27
+ : []
28
+ const resumeUniqueIds = Array.isArray(rec.resumeUniqueIds)
29
+ ? rec.resumeUniqueIds.map(String).filter(Boolean)
30
+ : []
31
+ const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
32
+ const pageSize =
33
+ Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
34
+ ? Math.floor(pageSizeRaw)
35
+ : 20
36
+ return { names, agentUniqueIds, resumeUniqueIds, pageSize }
37
+ }
38
+
39
+ function filterKey(filter: FormResumeFilter): string {
40
+ return JSON.stringify(filter)
41
+ }
42
+
43
+ const SkoponResumeSelectApi = {
44
+ name: 'SkoponResumeSelect',
45
+ schema: z
46
+ .object({
47
+ label: z.any().optional(),
48
+ placeholder: z.any().optional(),
49
+ help: z.any().optional(),
50
+ enableRefresh: z.any().optional(),
51
+ resumeFilter: z.any().optional(),
52
+ value: z.any().optional(),
53
+ })
54
+ .passthrough(),
55
+ }
56
+
57
+ function ResumeCard({
58
+ item,
59
+ selected,
60
+ disabled,
61
+ onToggle,
62
+ }: {
63
+ item: ResumeSearchItem
64
+ selected: boolean
65
+ disabled: boolean
66
+ onToggle: () => void
67
+ }) {
68
+ return (
69
+ <button
70
+ type="button"
71
+ className={`skopon-resume-select-card${selected ? ' skopon-resume-select-card--selected' : ''}`}
72
+ disabled={disabled}
73
+ onClick={onToggle}
74
+ >
75
+ <Avatar
76
+ size={48}
77
+ src={item.avatarUrl || undefined}
78
+ icon={!item.avatarUrl ? <UserOutlined /> : undefined}
79
+ className="skopon-resume-select-card-avatar"
80
+ />
81
+ <div className="skopon-resume-select-card-body">
82
+ <div className="skopon-resume-select-card-name">{item.name}</div>
83
+ {item.description ? (
84
+ <div className="skopon-resume-select-card-desc">{item.description}</div>
85
+ ) : null}
86
+ <div className="skopon-resume-select-card-meta">
87
+ {item.resumeUniqueId}
88
+ {typeof item.satisfaction === 'number' ? ` · 满意度 ${item.satisfaction}%` : ''}
89
+ </div>
90
+ </div>
91
+ </button>
92
+ )
93
+ }
94
+
95
+ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
96
+ const { interactive } = useA2uiPreviewMode()
97
+ const resumeSearch = useResumeSearch()
98
+ const { value, setValue } = useSkoponBoundField(context)
99
+ const props = context.componentModel.properties as Record<string, unknown>
100
+
101
+ const label = readString(props.label)
102
+ const placeholder = readString(props.placeholder)
103
+ const help = readString(props.help)
104
+ const enableRefresh = props.enableRefresh !== false
105
+ const resumeFilter = useMemo(() => readResumeFilter(props.resumeFilter), [props.resumeFilter])
106
+ const filterSig = filterKey(resumeFilter)
107
+
108
+ const [page, setPage] = useState(1)
109
+ const [loading, setLoading] = useState(false)
110
+ const [error, setError] = useState('')
111
+ const [items, setItems] = useState<ResumeSearchItem[]>([])
112
+ const [total, setTotal] = useState(0)
113
+ const [pageSize, setPageSize] = useState(resumeFilter.pageSize ?? 20)
114
+ const requestSeqRef = useRef(0)
115
+
116
+ const selectedIds = asStringArray(value)
117
+
118
+ const fetchPage = useCallback(
119
+ async (targetPage: number) => {
120
+ const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
121
+ const seq = ++requestSeqRef.current
122
+ if (!resumeSearch) {
123
+ setItems([])
124
+ setTotal(0)
125
+ setError('未配置简历搜索服务')
126
+ return
127
+ }
128
+ setLoading(true)
129
+ setError('')
130
+ try {
131
+ const result = await resumeSearch({ filter, page: targetPage })
132
+ if (seq !== requestSeqRef.current) return
133
+ setItems(result.list)
134
+ setTotal(result.total)
135
+ setPage(result.page)
136
+ setPageSize(result.pageSize)
137
+ } catch (err) {
138
+ if (seq !== requestSeqRef.current) return
139
+ setItems([])
140
+ setTotal(0)
141
+ setError(err instanceof Error ? err.message : '加载简历列表失败')
142
+ } finally {
143
+ if (seq === requestSeqRef.current) setLoading(false)
144
+ }
145
+ },
146
+ [resumeSearch, filterSig],
147
+ )
148
+
149
+ useEffect(() => {
150
+ setPage(1)
151
+ void fetchPage(1)
152
+ }, [filterSig, fetchPage])
153
+
154
+ const toggleSelect = (resumeUniqueId: string) => {
155
+ if (!interactive) return
156
+ const next = selectedIds.includes(resumeUniqueId)
157
+ ? selectedIds.filter((id) => id !== resumeUniqueId)
158
+ : [...selectedIds, resumeUniqueId]
159
+ setValue(next)
160
+ }
161
+
162
+ const handleRefresh = () => {
163
+ const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
164
+ const effectivePageSize = pageSize || filter.pageSize || 20
165
+ const totalPages = Math.max(1, Math.ceil(total / effectivePageSize))
166
+ const nextPage = page >= totalPages ? 1 : page + 1
167
+ void fetchPage(nextPage)
168
+ }
169
+
170
+ const emptyLabel = placeholder || '暂无匹配的简历'
171
+
172
+ return (
173
+ <div className="form-block-preview skopon-resume-select">
174
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
175
+ {loading && items.length === 0 ? (
176
+ <div className="skopon-resume-select-status">
177
+ <Spin size="small" /> 加载简历…
178
+ </div>
179
+ ) : null}
180
+ {!loading && error ? (
181
+ <Typography.Text type="danger" className="skopon-resume-select-status">
182
+ {error}
183
+ </Typography.Text>
184
+ ) : null}
185
+ {!loading && !error && items.length === 0 ? (
186
+ <div className="skopon-resume-select-empty">{emptyLabel}</div>
187
+ ) : null}
188
+ {items.length > 0 ? (
189
+ <div className="skopon-resume-select-list">
190
+ {items.map((item) => (
191
+ <ResumeCard
192
+ key={item.resumeUniqueId}
193
+ item={item}
194
+ selected={selectedIds.includes(item.resumeUniqueId)}
195
+ disabled={!interactive}
196
+ onToggle={() => toggleSelect(item.resumeUniqueId)}
197
+ />
198
+ ))}
199
+ </div>
200
+ ) : null}
201
+ {enableRefresh && resumeSearch ? (
202
+ <div className="skopon-resume-select-actions">
203
+ <Button
204
+ type="default"
205
+ size="small"
206
+ icon={<ReloadOutlined />}
207
+ loading={loading}
208
+ disabled={!interactive}
209
+ onClick={handleRefresh}
210
+ >
211
+ 换一批
212
+ </Button>
213
+ </div>
214
+ ) : null}
215
+ {help ? (
216
+ <Typography.Text type="secondary" className="form-block-preview-help">
217
+ {help}
218
+ </Typography.Text>
219
+ ) : null}
220
+ </div>
221
+ )
222
+ }
223
+
224
+ export const SkoponResumeSelectImpl = createBinderlessComponentImplementation(
225
+ SkoponResumeSelectApi,
226
+ SkoponResumeSelectPreview,
227
+ )
@@ -12,6 +12,8 @@ import {
12
12
  payloadHasInputBlocks,
13
13
  } from '../submit/intersectPayloadBlocksWithForm'
14
14
  import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
15
+ import type { CaseSearchFn } from '../catalog/caseSearchContext'
16
+ import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
15
17
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
16
18
  import CurlSubmitBlock from './CurlSubmitBlock'
17
19
 
@@ -37,6 +39,10 @@ export interface AskUserFormCardProps {
37
39
  /** 需 useCallback 稳定引用,避免重复拉取 */
38
40
  fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
39
41
  onNotify?: (type: 'success' | 'error', message: string) => void
42
+ /** 简历多选组件:注入搜索函数 */
43
+ resumeSearch?: ResumeSearchFn | null
44
+ /** 案例单选组件:注入搜索函数 */
45
+ caseSearch?: CaseSearchFn | null
40
46
  }
41
47
 
42
48
  export default function AskUserFormCard({
@@ -46,6 +52,8 @@ export default function AskUserFormCard({
46
52
  submitMode = 'curl',
47
53
  fetchFormDetail,
48
54
  onNotify,
55
+ resumeSearch = null,
56
+ caseSearch = null,
49
57
  }: AskUserFormCardProps) {
50
58
  const [formDef, setFormDef] = useState<FormSchema | null>(null)
51
59
  const [formDisabled, setFormDisabled] = useState(false)
@@ -213,6 +221,8 @@ export default function AskUserFormCard({
213
221
  surfaceId={surfaceId}
214
222
  fieldNames={fieldNames}
215
223
  interactive
224
+ resumeSearch={resumeSearch}
225
+ caseSearch={caseSearch}
216
226
  />
217
227
 
218
228
  <div className="ask-user-form-actions">
@@ -11,12 +11,12 @@ function createProcessor(): MessageProcessor<ReactComponentImplementation> {
11
11
  return new MessageProcessor([buildSkoponCatalog()])
12
12
  }
13
13
 
14
- function buildStreamMessages(surfaceId: string): A2uiMessage[] {
14
+ function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
15
15
  const doc = blocksToA2ui(
16
16
  {
17
17
  title: '',
18
18
  description: '',
19
- blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
19
+ blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
20
20
  },
21
21
  { includeHeader: false },
22
22
  )
@@ -59,4 +59,20 @@ describe('SkoponA2uiStreamRenderer stream lifecycle', () => {
59
59
  rebuilt.processMessages(messages.slice(0, 1))
60
60
  expect(rebuilt.model.surfacesMap.size).toBeLessThanOrEqual(countAfterFull)
61
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
+ })
62
78
  })
@@ -18,12 +18,12 @@ import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
18
18
  import { SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
19
19
  import SkoponA2uiStreamRenderer from './SkoponA2uiStreamRenderer'
20
20
 
21
- function buildStreamMessages(surfaceId: string): A2uiMessage[] {
21
+ function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
22
22
  const doc = blocksToA2ui(
23
23
  {
24
24
  title: '',
25
25
  description: '',
26
- blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
26
+ blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
27
27
  },
28
28
  { includeHeader: false },
29
29
  )
@@ -76,4 +76,28 @@ describe('SkoponA2uiStreamRenderer component', () => {
76
76
  expect(getByText('姓名')).toBeTruthy()
77
77
  expect(queryByText('stream-empty')).toBeNull()
78
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
+ })
79
103
  })