@skopon-cool/form-sdk 0.2.2 → 0.2.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.
@@ -0,0 +1,19 @@
1
+ import type { FormSchema } from '../types/index';
2
+ import { type PayloadBlocksDefinition } from './intersectPayloadBlocksWithForm';
3
+ export interface AskUserAnswerAgentContext {
4
+ missionId?: number | string | null;
5
+ tenantId?: number | null;
6
+ tenantUserId?: string | null;
7
+ requestId?: string | null;
8
+ askUserQueryDescription?: string | null;
9
+ callbackUrl?: string | null;
10
+ }
11
+ export interface BuildAnswerAgentBodyOptions {
12
+ context: AskUserAnswerAgentContext;
13
+ payload: unknown;
14
+ form?: PayloadBlocksDefinition | FormSchema | null;
15
+ answer: Record<string, unknown>;
16
+ }
17
+ /** 组装 answer-agent 外包消息体(与 mock AskUserAnswerMockVo 对齐)。 */
18
+ export declare function buildAnswerAgentBody({ context, payload, form, answer, }: BuildAnswerAgentBodyOptions): Record<string, unknown>;
19
+ //# sourceMappingURL=buildAnswerAgentBody.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildAnswerAgentBody.d.ts","sourceRoot":"","sources":["../../src/submit/buildAnswerAgentBody.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,EAEL,KAAK,uBAAuB,EAC7B,MAAM,kCAAkC,CAAA;AAEzC,MAAM,WAAW,yBAAyB;IACxC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAClC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAmBD,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,yBAAyB,CAAA;IAClC,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,uBAAuB,GAAG,UAAU,GAAG,IAAI,CAAA;IAClD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAChC;AAED,4DAA4D;AAC5D,wBAAgB,oBAAoB,CAAC,EACnC,OAAO,EACP,OAAO,EACP,IAAI,EACJ,MAAM,GACP,EAAE,2BAA2B,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAevD"}
@@ -0,0 +1,9 @@
1
+ import { type SkoponAuthStorageOptions } from '../client/authStorage';
2
+ import { type SubmitFormJsonOptions, type SubmitFormJsonResult } from './submitFormJson';
3
+ export interface SubmitAnswerAgentOptions extends SubmitFormJsonOptions {
4
+ authStorage?: SkoponAuthStorageOptions;
5
+ /** 显式覆盖 auth_user 中的 answer_agent_callback_url */
6
+ answerAgentCallbackUrl?: string | null;
7
+ }
8
+ export declare function submitAnswerAgent(body: Record<string, unknown>, options?: SubmitAnswerAgentOptions): Promise<SubmitFormJsonResult>;
9
+ //# sourceMappingURL=submitAnswerAgent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submitAnswerAgent.d.ts","sourceRoot":"","sources":["../../src/submit/submitAnswerAgent.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAEL,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EAC1B,MAAM,kBAAkB,CAAA;AAEzB,MAAM,WAAW,wBAAyB,SAAQ,qBAAqB;IACrE,WAAW,CAAC,EAAE,wBAAwB,CAAA;IACtC,kDAAkD;IAClD,sBAAsB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvC;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,CAAC,EAAE,wBAAwB,GACjC,OAAO,CAAC,oBAAoB,CAAC,CAkB/B"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.2",
6
+ "version": "0.2.3",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -3,7 +3,9 @@
3
3
  */
4
4
  import { afterEach, describe, expect, it } from 'vitest'
5
5
  import {
6
+ DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
6
7
  DEFAULT_AUTH_STORAGE_KEY,
8
+ resolveAnswerAgentCallbackUrl,
7
9
  resolveSkoponAuthHeaders,
8
10
  } from './authStorage'
9
11
 
@@ -59,3 +61,27 @@ describe('resolveSkoponAuthHeaders', () => {
59
61
  })
60
62
  })
61
63
  })
64
+
65
+ describe('resolveAnswerAgentCallbackUrl', () => {
66
+ afterEach(() => {
67
+ sessionStorage.clear()
68
+ localStorage.clear()
69
+ })
70
+
71
+ it('reads answer_agent_callback_url from sessionStorage auth_user', () => {
72
+ sessionStorage.setItem(
73
+ DEFAULT_AUTH_STORAGE_KEY,
74
+ JSON.stringify({
75
+ token: 'tok',
76
+ [DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD]:
77
+ '/api/v1/mock/ask_user_answer_mock',
78
+ }),
79
+ )
80
+ expect(resolveAnswerAgentCallbackUrl()).toBe('/api/v1/mock/ask_user_answer_mock')
81
+ })
82
+
83
+ it('returns empty string when field is missing', () => {
84
+ sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: 'tok' }))
85
+ expect(resolveAnswerAgentCallbackUrl()).toBe('')
86
+ })
87
+ })
@@ -9,6 +9,7 @@ export interface SkoponAuthStorageOptions {
9
9
 
10
10
  export const DEFAULT_AUTH_STORAGE_KEY = 'auth_user'
11
11
  export const DEFAULT_AUTH_TOKEN_FIELD = 'token'
12
+ export const DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD = 'answer_agent_callback_url'
12
13
 
13
14
  function readStorage(
14
15
  kind: 'sessionStorage' | 'localStorage',
@@ -21,19 +22,50 @@ function readStorage(
21
22
  }
22
23
  }
23
24
 
25
+ function readSessionJson(raw: string): Record<string, unknown> | null {
26
+ try {
27
+ const parsed = JSON.parse(raw) as unknown
28
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
29
+ return parsed as Record<string, unknown>
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
24
35
  function readTokenFromSessionJson(
25
36
  raw: string,
26
37
  tokenField: string,
27
38
  ): string {
28
- try {
29
- const parsed = JSON.parse(raw) as Record<string, unknown>
30
- const token = parsed[tokenField]
31
- return typeof token === 'string'
32
- ? token.trim().replace(/^bearer\s+/i, '').trim()
33
- : ''
34
- } catch {
35
- return ''
36
- }
39
+ const parsed = readSessionJson(raw)
40
+ if (!parsed) return ''
41
+ const token = parsed[tokenField]
42
+ return typeof token === 'string'
43
+ ? token.trim().replace(/^bearer\s+/i, '').trim()
44
+ : ''
45
+ }
46
+
47
+ /** 从宿主登录缓存读取 answer_agent_callback_url(无则返回空串)。 */
48
+ export function resolveAnswerAgentCallbackUrl(
49
+ options: SkoponAuthStorageOptions & {
50
+ answerAgentCallbackUrlField?: string
51
+ } = {},
52
+ ): string {
53
+ const storageKind = options.storage ?? 'sessionStorage'
54
+ const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
55
+ const field =
56
+ options.answerAgentCallbackUrlField ?? DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD
57
+
58
+ const storage = readStorage(storageKind)
59
+ if (!storage) return ''
60
+
61
+ const raw = storage.getItem(storageKey)
62
+ if (!raw) return ''
63
+
64
+ const parsed = readSessionJson(raw)
65
+ if (!parsed) return ''
66
+
67
+ const url = parsed[field]
68
+ return typeof url === 'string' ? url.trim() : ''
37
69
  }
38
70
 
39
71
  /** 从宿主登录缓存读取 Bearer token,返回 Authorization 头(无 token 时返回空对象)。 */
@@ -2,7 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
2
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 { buildAskUserCurlStatement } from '../submit/buildCurlStatement'
5
+ import {
6
+ buildAnswerAgentBody,
7
+ type AskUserAnswerAgentContext,
8
+ } from '../submit/buildAnswerAgentBody'
6
9
  import {
7
10
  extractExtraBlockValues,
8
11
  getPayloadInputFieldNames,
@@ -11,7 +14,8 @@ import {
11
14
  parsePayloadBlocksJson,
12
15
  payloadHasInputBlocks,
13
16
  } from '../submit/intersectPayloadBlocksWithForm'
14
- import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
17
+ import { submitFormJson } from '../submit/submitFormJson'
18
+ import { submitAnswerAgent } from '../submit/submitAnswerAgent'
15
19
  import { validateFormSubmitValues } from '../submit/validateFormSubmit'
16
20
  import type { CaseSearchFn } from '../catalog/caseSearchContext'
17
21
  import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
@@ -38,6 +42,7 @@ export interface AskUserFormCardProps {
38
42
  formUniqueId?: string | null
39
43
  callbackUrl?: string | null
40
44
  submitMode?: SubmitMode
45
+ answerAgentContext?: AskUserAnswerAgentContext
41
46
  /** 需 useCallback 稳定引用,避免重复拉取 */
42
47
  fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
43
48
  onNotify?: (type: 'success' | 'error', message: string) => void
@@ -52,6 +57,7 @@ export default function AskUserFormCard({
52
57
  formUniqueId,
53
58
  callbackUrl,
54
59
  submitMode = 'curl',
60
+ answerAgentContext,
55
61
  fetchFormDetail,
56
62
  onNotify,
57
63
  resumeSearch = null,
@@ -68,6 +74,14 @@ export default function AskUserFormCard({
68
74
 
69
75
  const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
70
76
 
77
+ const resolvedAnswerAgentContext = useMemo(
78
+ (): AskUserAnswerAgentContext => ({
79
+ ...answerAgentContext,
80
+ callbackUrl: answerAgentContext?.callbackUrl ?? callbackUrl ?? null,
81
+ }),
82
+ [answerAgentContext, callbackUrl],
83
+ )
84
+
71
85
  useEffect(() => {
72
86
  mountedRef.current = true
73
87
  return () => {
@@ -170,7 +184,7 @@ export default function AskUserFormCard({
170
184
  <CurlSubmitBlock
171
185
  payload={payload}
172
186
  callbackUrl={callbackUrl}
173
- copyMode="submitFields"
187
+ answerAgentContext={resolvedAnswerAgentContext}
174
188
  unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
175
189
  onNotify={onNotify}
176
190
  />
@@ -188,7 +202,7 @@ export default function AskUserFormCard({
188
202
  onNotifyRef.current?.('error', validationError.message)
189
203
  return
190
204
  }
191
- const submitBody = { ...cardValues, ...extraValues }
205
+ const answer = { ...cardValues, ...extraValues }
192
206
 
193
207
  if (submitMode === 'post') {
194
208
  const url = (callbackUrl ?? '').trim()
@@ -196,7 +210,7 @@ export default function AskUserFormCard({
196
210
  onNotifyRef.current?.('error', 'callback_url 为空,无法提交')
197
211
  return
198
212
  }
199
- const result = await submitFormJson(url, submitBody)
213
+ const result = await submitFormJson(url, answer)
200
214
  if (!mountedRef.current) return
201
215
  if (result.ok) {
202
216
  onNotifyRef.current?.('success', '提交成功')
@@ -206,14 +220,22 @@ export default function AskUserFormCard({
206
220
  return
207
221
  }
208
222
 
209
- await copyTextToClipboard(
210
- buildAskUserCurlStatement({ cardValues, extraValues, callbackUrl }),
211
- )
223
+ const body = buildAnswerAgentBody({
224
+ context: resolvedAnswerAgentContext,
225
+ payload,
226
+ form: formDef ?? payloadDef,
227
+ answer,
228
+ })
229
+ const result = await submitAnswerAgent(body)
212
230
  if (!mountedRef.current) return
213
- onNotifyRef.current?.('success', '已复制 curl 到剪贴板')
231
+ if (result.ok) {
232
+ onNotifyRef.current?.('success', '提交成功')
233
+ } else {
234
+ onNotifyRef.current?.('error', formatSubmitErrorMessage(result.status, result.body))
235
+ }
214
236
  } catch (error) {
215
237
  if (!mountedRef.current) return
216
- const detail = error instanceof Error ? error.message : '复制失败'
238
+ const detail = error instanceof Error ? error.message : '提交失败'
217
239
  onNotifyRef.current?.('error', detail)
218
240
  } finally {
219
241
  if (mountedRef.current) setSubmitting(false)
@@ -242,7 +264,13 @@ export default function AskUserFormCard({
242
264
  />
243
265
 
244
266
  <div className="ask-user-form-actions">
245
- <Tooltip title={submitMode === 'curl' ? '点击复制 curl' : '提交 JSON 到 callback_url'}>
267
+ <Tooltip
268
+ title={
269
+ submitMode === 'post'
270
+ ? '提交 JSON 到 callback_url'
271
+ : '提交到 answer_agent_callback_url'
272
+ }
273
+ >
246
274
  <Button
247
275
  type="primary"
248
276
  size="small"
@@ -1,10 +1,15 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, useState } from 'react'
2
2
  import { Button, Tag, Tooltip, Typography } from 'antd'
3
+ import { buildCurlStatement } from '../submit/buildCurlStatement'
3
4
  import {
4
- buildAskUserSubmitCurlFromPayload,
5
- buildCurlStatement,
6
- } from '../submit/buildCurlStatement'
7
- import { copyTextToClipboard } from '../submit/submitFormJson'
5
+ buildAnswerAgentBody,
6
+ type AskUserAnswerAgentContext,
7
+ } from '../submit/buildAnswerAgentBody'
8
+ import {
9
+ extractExtraBlockValues,
10
+ parsePayloadBlocksJson,
11
+ } from '../submit/intersectPayloadBlocksWithForm'
12
+ import { submitAnswerAgent } from '../submit/submitAnswerAgent'
8
13
 
9
14
  export type CurlSubmitBlockCopyMode = 'display' | 'submitFields'
10
15
 
@@ -16,43 +21,80 @@ export interface CurlSubmitBlockProps {
16
21
  incompleteFormId?: string | null
17
22
  /** display:复制与显示一致;submitFields:复制向 callback 提交返回字段的 curl */
18
23
  copyMode?: CurlSubmitBlockCopyMode
24
+ answerAgentContext?: AskUserAnswerAgentContext
19
25
  onNotify?: (type: 'success' | 'error', message: string) => void
20
26
  }
21
27
 
28
+ function formatSubmitErrorMessage(status: number, body: unknown): string {
29
+ if (body && typeof body === 'object' && body !== null && 'message' in body) {
30
+ const msg = (body as { message: unknown }).message
31
+ if (typeof msg === 'string' && msg.trim()) {
32
+ return `提交失败 (${status}): ${msg.trim()}`
33
+ }
34
+ }
35
+ if (typeof body === 'string' && body.trim()) {
36
+ return `提交失败 (${status}): ${body.trim()}`
37
+ }
38
+ return `提交失败 (${status})`
39
+ }
40
+
41
+ function resolveAnswerFromPayload(payload: unknown): Record<string, unknown> {
42
+ const def = parsePayloadBlocksJson(payload)
43
+ if (def) return extractExtraBlockValues(def.blocks)
44
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
45
+ return payload as Record<string, unknown>
46
+ }
47
+ return {}
48
+ }
49
+
22
50
  export default function CurlSubmitBlock({
23
51
  payload,
24
52
  callbackUrl,
25
53
  title,
26
54
  unpublishedFormId,
27
55
  incompleteFormId,
28
- copyMode = 'display',
56
+ answerAgentContext,
29
57
  onNotify,
30
58
  }: CurlSubmitBlockProps) {
59
+ const [submitting, setSubmitting] = useState(false)
60
+
31
61
  const displayCurl = useMemo(
32
62
  () => buildCurlStatement(payload, callbackUrl),
33
63
  [payload, callbackUrl],
34
64
  )
35
- const copyCurl = useMemo(() => {
36
- if (copyMode === 'submitFields') {
37
- return buildAskUserSubmitCurlFromPayload(payload, callbackUrl)
38
- }
39
- return displayCurl
40
- }, [copyMode, payload, callbackUrl, displayCurl])
41
65
 
42
- async function handleCopy() {
66
+ const resolvedContext = useMemo(
67
+ (): AskUserAnswerAgentContext => ({
68
+ ...answerAgentContext,
69
+ callbackUrl: answerAgentContext?.callbackUrl ?? callbackUrl ?? null,
70
+ }),
71
+ [answerAgentContext, callbackUrl],
72
+ )
73
+
74
+ async function handleSubmit() {
75
+ if (submitting) return
76
+ setSubmitting(true)
43
77
  try {
44
- await copyTextToClipboard(copyCurl)
45
- onNotify?.('success', '已复制到剪贴板')
46
- } catch {
47
- onNotify?.('error', '复制失败')
78
+ const answer = resolveAnswerFromPayload(payload)
79
+ const body = buildAnswerAgentBody({
80
+ context: resolvedContext,
81
+ payload,
82
+ answer,
83
+ })
84
+ const result = await submitAnswerAgent(body)
85
+ if (result.ok) {
86
+ onNotify?.('success', '提交成功')
87
+ } else {
88
+ onNotify?.('error', formatSubmitErrorMessage(result.status, result.body))
89
+ }
90
+ } catch (error) {
91
+ const detail = error instanceof Error ? error.message : '提交失败'
92
+ onNotify?.('error', detail)
93
+ } finally {
94
+ setSubmitting(false)
48
95
  }
49
96
  }
50
97
 
51
- const copyTooltip =
52
- copyMode === 'submitFields'
53
- ? '复制提交字段 curl(向 callback 回传)'
54
- : '复制 curl'
55
-
56
98
  return (
57
99
  <div className="ask-user-curl-card">
58
100
  <div className="ask-user-curl-card-header">
@@ -69,9 +111,15 @@ export default function CurlSubmitBlock({
69
111
  </Tooltip>
70
112
  ) : null}
71
113
  </div>
72
- <Tooltip title={copyTooltip}>
73
- <Button size="small" type="text" onClick={() => void handleCopy()}>
74
- 复制
114
+ <Tooltip title="提交到 answer_agent_callback_url">
115
+ <Button
116
+ size="small"
117
+ type="text"
118
+ loading={submitting}
119
+ disabled={submitting}
120
+ onClick={() => void handleSubmit()}
121
+ >
122
+ 提交
75
123
  </Button>
76
124
  </Tooltip>
77
125
  </div>
package/src/index.ts CHANGED
@@ -66,10 +66,21 @@ export {
66
66
 
67
67
  export { createFormClient, type FormClient, type FormClientOptions } from './client/formClient'
68
68
  export {
69
+ buildAnswerAgentBody,
70
+ type AskUserAnswerAgentContext,
71
+ type BuildAnswerAgentBodyOptions,
72
+ } from './submit/buildAnswerAgentBody'
73
+ export {
74
+ submitAnswerAgent,
75
+ type SubmitAnswerAgentOptions,
76
+ } from './submit/submitAnswerAgent'
77
+ export {
78
+ resolveAnswerAgentCallbackUrl,
69
79
  resolveSkoponAuthHeaders,
70
80
  resolveClientHeaders,
71
81
  DEFAULT_AUTH_STORAGE_KEY,
72
82
  DEFAULT_AUTH_TOKEN_FIELD,
83
+ DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
73
84
  type SkoponAuthStorageOptions,
74
85
  type ClientHeadersOptions,
75
86
  } from './client/authStorage'
@@ -0,0 +1,61 @@
1
+ import type { FormSchema } from '../types/index'
2
+ import {
3
+ parsePayloadBlocksJson,
4
+ type PayloadBlocksDefinition,
5
+ } from './intersectPayloadBlocksWithForm'
6
+
7
+ export interface AskUserAnswerAgentContext {
8
+ missionId?: number | string | null
9
+ tenantId?: number | null
10
+ tenantUserId?: string | null
11
+ requestId?: string | null
12
+ askUserQueryDescription?: string | null
13
+ callbackUrl?: string | null
14
+ }
15
+
16
+ function toBlocksJsonShape(
17
+ source: PayloadBlocksDefinition | FormSchema | unknown,
18
+ ): unknown {
19
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
20
+ return source ?? null
21
+ }
22
+ const obj = source as Record<string, unknown>
23
+ if (!Array.isArray(obj.blocks)) {
24
+ return source
25
+ }
26
+ return {
27
+ title: typeof obj.title === 'string' ? obj.title : '',
28
+ description: typeof obj.description === 'string' ? obj.description : '',
29
+ blocks: obj.blocks,
30
+ }
31
+ }
32
+
33
+ export interface BuildAnswerAgentBodyOptions {
34
+ context: AskUserAnswerAgentContext
35
+ payload: unknown
36
+ form?: PayloadBlocksDefinition | FormSchema | null
37
+ answer: Record<string, unknown>
38
+ }
39
+
40
+ /** 组装 answer-agent 外包消息体(与 mock AskUserAnswerMockVo 对齐)。 */
41
+ export function buildAnswerAgentBody({
42
+ context,
43
+ payload,
44
+ form,
45
+ answer,
46
+ }: BuildAnswerAgentBodyOptions): Record<string, unknown> {
47
+ const question = parsePayloadBlocksJson(payload) ?? payload
48
+ const formBody = form ? toBlocksJsonShape(form) : question
49
+
50
+ return {
51
+ mission_id: context.missionId ?? null,
52
+ tenant_id: context.tenantId ?? null,
53
+ tenant_user_id: context.tenantUserId ?? null,
54
+ request_id: context.requestId ?? null,
55
+ ask_user_query_description: context.askUserQueryDescription ?? null,
56
+ callback_url: (context.callbackUrl ?? '').trim() || null,
57
+ form: formBody,
58
+ question,
59
+ answer,
60
+ }
61
+ }
@@ -8,6 +8,7 @@ import {
8
8
  buildAskUserSubmitCurlFromPayload,
9
9
  buildCurlStatement,
10
10
  } from '../submit/buildCurlStatement'
11
+ import { buildAnswerAgentBody } from '../submit/buildAnswerAgentBody'
11
12
  import {
12
13
  extractExtraBlockValues,
13
14
  getPayloadInputFieldNames,
@@ -16,6 +17,7 @@ import {
16
17
  parsePayloadBlocksJson,
17
18
  payloadHasInputBlocks,
18
19
  } from '../submit/intersectPayloadBlocksWithForm'
20
+ import { submitAnswerAgent } from '../submit/submitAnswerAgent'
19
21
  import { submitFormJson } from '../submit/submitFormJson'
20
22
  import { validateFormSubmitValues } from '../submit/validateFormSubmit'
21
23
  import type { FormSchema } from '../types/index'
@@ -261,6 +263,79 @@ describe('payload fallback helpers', () => {
261
263
  })
262
264
  })
263
265
 
266
+ describe('buildAnswerAgentBody', () => {
267
+ it('maps context and payload into answer-agent body', () => {
268
+ const payload = {
269
+ title: '需求描述',
270
+ blocks: [
271
+ { id: 'b1', type: 'textarea', name: 'field_b6ba0dbb', label: '需求描述' },
272
+ ],
273
+ }
274
+ const body = buildAnswerAgentBody({
275
+ context: {
276
+ missionId: 12,
277
+ tenantId: 1001,
278
+ tenantUserId: 'user-1',
279
+ requestId: 'req-1',
280
+ askUserQueryDescription: '请确认',
281
+ callbackUrl: 'https://example.com/cb',
282
+ },
283
+ payload,
284
+ answer: { field_b6ba0dbb: 'hello' },
285
+ })
286
+ expect(body.mission_id).toBe(12)
287
+ expect(body.tenant_id).toBe(1001)
288
+ expect(body.tenant_user_id).toBe('user-1')
289
+ expect(body.request_id).toBe('req-1')
290
+ expect(body.ask_user_query_description).toBe('请确认')
291
+ expect(body.callback_url).toBe('https://example.com/cb')
292
+ expect(body.answer).toEqual({ field_b6ba0dbb: 'hello' })
293
+ expect(body.question).toMatchObject({ title: '需求描述' })
294
+ expect(body.form).toMatchObject({ title: '需求描述' })
295
+ expect((body.question as { blocks: Array<{ name: string }> }).blocks[0]?.name).toBe(
296
+ 'field_b6ba0dbb',
297
+ )
298
+ })
299
+ })
300
+
301
+ describe('submitAnswerAgent', () => {
302
+ it('POSTs to explicit answer agent callback url with auth headers', async () => {
303
+ const fetchMock = vi.fn(async () => ({
304
+ ok: true,
305
+ status: 200,
306
+ headers: { get: () => 'application/json' },
307
+ json: async () => ({ callback_ok: true }),
308
+ })) as unknown as typeof fetch
309
+
310
+ const result = await submitAnswerAgent(
311
+ { answer: { a: 1 } },
312
+ {
313
+ answerAgentCallbackUrl: '/api/v1/mock/ask_user_answer_mock',
314
+ headers: { Authorization: 'Bearer tok' },
315
+ fetch: fetchMock,
316
+ },
317
+ )
318
+ expect(result.ok).toBe(true)
319
+ expect(fetchMock).toHaveBeenCalledWith(
320
+ '/api/v1/mock/ask_user_answer_mock',
321
+ expect.objectContaining({
322
+ method: 'POST',
323
+ headers: expect.objectContaining({
324
+ Authorization: 'Bearer tok',
325
+ 'Content-Type': 'application/json',
326
+ }),
327
+ body: JSON.stringify({ answer: { a: 1 } }),
328
+ }),
329
+ )
330
+ })
331
+
332
+ it('throws when answer agent callback url is missing', async () => {
333
+ await expect(submitAnswerAgent({ answer: {} })).rejects.toThrow(
334
+ 'answer_agent_callback_url 未配置,无法提交',
335
+ )
336
+ })
337
+ })
338
+
264
339
  describe('submitFormJson', () => {
265
340
  it('POSTs JSON payload', async () => {
266
341
  const fetchMock = vi.fn(async () => ({
@@ -0,0 +1,39 @@
1
+ import {
2
+ resolveAnswerAgentCallbackUrl,
3
+ resolveSkoponAuthHeaders,
4
+ type SkoponAuthStorageOptions,
5
+ } from '../client/authStorage'
6
+ import {
7
+ submitFormJson,
8
+ type SubmitFormJsonOptions,
9
+ type SubmitFormJsonResult,
10
+ } from './submitFormJson'
11
+
12
+ export interface SubmitAnswerAgentOptions extends SubmitFormJsonOptions {
13
+ authStorage?: SkoponAuthStorageOptions
14
+ /** 显式覆盖 auth_user 中的 answer_agent_callback_url */
15
+ answerAgentCallbackUrl?: string | null
16
+ }
17
+
18
+ export async function submitAnswerAgent(
19
+ body: Record<string, unknown>,
20
+ options?: SubmitAnswerAgentOptions,
21
+ ): Promise<SubmitFormJsonResult> {
22
+ const url = (
23
+ options?.answerAgentCallbackUrl ??
24
+ resolveAnswerAgentCallbackUrl(options?.authStorage)
25
+ ).trim()
26
+ if (!url) {
27
+ throw new Error('answer_agent_callback_url 未配置,无法提交')
28
+ }
29
+
30
+ const storageHeaders = resolveSkoponAuthHeaders(options?.authStorage)
31
+
32
+ return submitFormJson(url, body, {
33
+ ...options,
34
+ headers: {
35
+ ...storageHeaders,
36
+ ...(options?.headers ?? {}),
37
+ },
38
+ })
39
+ }