flare-chat-core 0.2.0 → 0.2.1

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flare-chat-core",
3
3
  "private": false,
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -21,6 +21,9 @@ export function buildTimelineItems(state = {}, options = {}) {
21
21
  role: msg.role,
22
22
  content: msg.content,
23
23
  createdAt: msg.created_at,
24
+ client_request_id: msg.client_request_id || '',
25
+ intake_session_id: msg.intake_session_id || '',
26
+ roundKey: msg.round_key || '',
24
27
  highlights: msg.highlights ?? [],
25
28
  sourceTypes: msg.sourceTypes ?? [],
26
29
  attachments: Array.isArray(msg.attachments) ? msg.attachments : [],
@@ -8,6 +8,7 @@ const MESSAGES_REFRESHED = 'MESSAGES_REFRESHED';
8
8
  const MESSAGE_APPENDED = 'MESSAGE_APPENDED';
9
9
  const STREAMING_RESET = 'STREAMING_RESET';
10
10
  const STREAMING_CHUNK = 'STREAMING_CHUNK';
11
+ const STREAMING_REPLACE = 'STREAMING_REPLACE';
11
12
  const AGENT_STATUS_SET = 'AGENT_STATUS_SET';
12
13
  const THINKING_TRACE_SET = 'THINKING_TRACE_SET';
13
14
  const EXECUTION_TRACE_SET = 'EXECUTION_TRACE_SET';
@@ -19,6 +20,7 @@ const UI_CARDS_SET = 'UI_CARDS_SET';
19
20
  const LAST_USER_MESSAGE_SET = 'LAST_USER_MESSAGE_SET';
20
21
  const UI_CARD_UPDATED = 'UI_CARD_UPDATED';
21
22
  const INSTANCE_PROFILE_SET = 'INSTANCE_PROFILE_SET';
23
+ const SESSION_PROMOTED = 'SESSION_PROMOTED';
22
24
 
23
25
  const initialStreaming = {
24
26
  content: '',
@@ -31,6 +33,7 @@ export const initialState = {
31
33
  sessionId: null,
32
34
  sessionTitle: '',
33
35
  sessionStatus: 'active',
36
+ sessionDetail: null,
34
37
  instanceProfile: null,
35
38
  messages: [],
36
39
  executionCards: [],
@@ -48,12 +51,19 @@ export const initialState = {
48
51
  function reducer(state, { type, payload }) {
49
52
  switch (type) {
50
53
  case SESSION_LOADED: {
51
- const { sessionId, title, status, messages } = payload;
54
+ const {
55
+ sessionId,
56
+ title,
57
+ status,
58
+ messages,
59
+ detail,
60
+ } = payload;
52
61
  return {
53
62
  ...state,
54
63
  sessionId,
55
64
  sessionTitle: title,
56
65
  sessionStatus: status || 'active',
66
+ sessionDetail: (detail && typeof detail === 'object' && !Array.isArray(detail)) ? detail : null,
57
67
  instanceProfile: state.instanceProfile,
58
68
  messages,
59
69
  executionCards: [],
@@ -103,6 +113,40 @@ function reducer(state, { type, payload }) {
103
113
  case INSTANCE_PROFILE_SET:
104
114
  return { ...state, instanceProfile: payload };
105
115
 
116
+ case SESSION_PROMOTED: {
117
+ const sessionId = String(payload?.sessionId || '').trim();
118
+ if (!sessionId) {
119
+ return state;
120
+ }
121
+ const title = String(payload?.title || '').trim();
122
+ const detailPayload = (
123
+ payload?.detail
124
+ && typeof payload.detail === 'object'
125
+ && !Array.isArray(payload.detail)
126
+ ) ? payload.detail : null;
127
+ const nextDetail = detailPayload
128
+ ? {
129
+ ...(state.sessionDetail && typeof state.sessionDetail === 'object' ? state.sessionDetail : {}),
130
+ ...detailPayload,
131
+ sessionId,
132
+ }
133
+ : (
134
+ state.sessionDetail && typeof state.sessionDetail === 'object'
135
+ ? {
136
+ ...state.sessionDetail,
137
+ sessionId,
138
+ }
139
+ : null
140
+ );
141
+ return {
142
+ ...state,
143
+ sessionId,
144
+ sessionTitle: title || state.sessionTitle,
145
+ sessionStatus: String(payload?.status || state.sessionStatus || 'active'),
146
+ sessionDetail: nextDetail,
147
+ };
148
+ }
149
+
106
150
  case EXECUTION_CARD_UPSERTED: {
107
151
  const nextCard = payload;
108
152
  const cardKey = nextCard.step_id || `${nextCard.agent}-${nextCard.label}-${nextCard.stage}`;
@@ -135,6 +179,15 @@ function reducer(state, { type, payload }) {
135
179
  },
136
180
  };
137
181
 
182
+ case STREAMING_REPLACE:
183
+ return {
184
+ ...state,
185
+ streaming: {
186
+ ...state.streaming,
187
+ content: String(payload || ''),
188
+ },
189
+ };
190
+
138
191
  case AGENT_STATUS_SET:
139
192
  return {
140
193
  ...state,
@@ -223,8 +276,10 @@ export default function useChatSessionReducer(deps = {}) {
223
276
  title: detail.title,
224
277
  status: detail.status || 'active',
225
278
  messages: msgs.messages,
279
+ detail,
226
280
  },
227
281
  });
282
+ return detail;
228
283
  }, [sessionAPI, messageAPI]);
229
284
 
230
285
  const createSession = useCallback(async ({
@@ -242,6 +297,7 @@ export default function useChatSessionReducer(deps = {}) {
242
297
  title,
243
298
  status: response.status || 'active',
244
299
  messages: [],
300
+ detail: response,
245
301
  },
246
302
  });
247
303
 
@@ -295,6 +351,7 @@ export default function useChatSessionReducer(deps = {}) {
295
351
  title,
296
352
  status: detail.status || 'active',
297
353
  messages: msgs.messages,
354
+ detail,
298
355
  },
299
356
  });
300
357
 
@@ -311,6 +368,10 @@ export default function useChatSessionReducer(deps = {}) {
311
368
  dispatch({ type: TITLE_UPDATED, payload: title });
312
369
  }, [sessionAPI]);
313
370
 
371
+ const promoteSession = useCallback((payload = {}) => {
372
+ dispatch({ type: SESSION_PROMOTED, payload });
373
+ }, []);
374
+
314
375
  const refreshMessages = useCallback(async (sessionId) => {
315
376
  const messageApi = requireAPI(messageAPI, 'messageAPI');
316
377
  const response = await messageApi.list(sessionId);
@@ -361,6 +422,10 @@ export default function useChatSessionReducer(deps = {}) {
361
422
  dispatch({ type: STREAMING_CHUNK, payload: chunk });
362
423
  }, []);
363
424
 
425
+ const replaceStreamContent = useCallback((content) => {
426
+ dispatch({ type: STREAMING_REPLACE, payload: content });
427
+ }, []);
428
+
364
429
  const setAgentStatus = useCallback((agentStatus) => {
365
430
  dispatch({ type: AGENT_STATUS_SET, payload: agentStatus });
366
431
  }, []);
@@ -397,6 +462,7 @@ export default function useChatSessionReducer(deps = {}) {
397
462
  return useMemo(() => ({
398
463
  sessionId: state.sessionId,
399
464
  sessionTitle: state.sessionTitle,
465
+ sessionDetail: state.sessionDetail,
400
466
  instanceProfile: state.instanceProfile,
401
467
  messages: state.messages,
402
468
  executionCards: state.executionCards,
@@ -411,6 +477,7 @@ export default function useChatSessionReducer(deps = {}) {
411
477
  createOrLoadSession,
412
478
  resetSession,
413
479
  updateTitle,
480
+ promoteSession,
414
481
  refreshMessages,
415
482
  setSessionError,
416
483
  appendUserMessage,
@@ -419,6 +486,7 @@ export default function useChatSessionReducer(deps = {}) {
419
486
  setLastUserMessage,
420
487
  resetStreaming,
421
488
  appendStreamChunk,
489
+ replaceStreamContent,
422
490
  setAgentStatus,
423
491
  setThinkingTrace,
424
492
  setExecutionTrace,
@@ -433,6 +501,7 @@ export default function useChatSessionReducer(deps = {}) {
433
501
  createOrLoadSession,
434
502
  resetSession,
435
503
  updateTitle,
504
+ promoteSession,
436
505
  refreshMessages,
437
506
  setSessionError,
438
507
  appendUserMessage,
@@ -441,6 +510,7 @@ export default function useChatSessionReducer(deps = {}) {
441
510
  setLastUserMessage,
442
511
  resetStreaming,
443
512
  appendStreamChunk,
513
+ replaceStreamContent,
444
514
  setAgentStatus,
445
515
  setThinkingTrace,
446
516
  setExecutionTrace,
@@ -13,11 +13,19 @@ function joinUrl(baseUrl, endpoint) {
13
13
  return `${baseUrl.replace(/\/+$/, '')}/${endpoint.replace(/^\/+/, '')}`;
14
14
  }
15
15
 
16
+ function buildClientRequestId() {
17
+ const random = Math.random().toString(36).slice(2, 10);
18
+ return `req_${Date.now()}_${random}`;
19
+ }
20
+
16
21
  export class SSEClient {
17
22
  constructor({ endpoint = DEFAULT_ENDPOINT, baseUrl = API_BASE_URL } = {}) {
18
23
  this.abortController = null;
19
24
  this.endpoint = endpoint;
20
25
  this.baseUrl = baseUrl;
26
+ this.activeRequestId = '';
27
+ this.authReadyPromise = null;
28
+ this.authReadyAtMs = 0;
21
29
  }
22
30
 
23
31
  async sendMessage(sessionOrParams, contentArg, onEventArg, onCompleteArg, onErrorArg, optionsArg = {}) {
@@ -35,7 +43,6 @@ export class SSEClient {
35
43
  const enabledCapabilities = Array.isArray(params.enabledCapabilities) ? params.enabledCapabilities : [];
36
44
  const modeKey = typeof params.modeKey === 'string' ? params.modeKey.trim() : '';
37
45
  const manualModeKey = typeof params.manualModeKey === 'string' ? params.manualModeKey.trim() : '';
38
- const currentModeKey = typeof params.currentModeKey === 'string' ? params.currentModeKey.trim() : '';
39
46
  const payloadExtra = (
40
47
  params.payloadExtra
41
48
  && typeof params.payloadExtra === 'object'
@@ -47,32 +54,111 @@ export class SSEClient {
47
54
  const onComplete = isObjectCall ? onEventArg : onCompleteArg;
48
55
  const onError = isObjectCall ? onCompleteArg : onErrorArg;
49
56
  const url = params.url || joinUrl(params.baseUrl ?? this.baseUrl, params.endpoint || this.endpoint);
50
-
57
+ const command = typeof params.command === 'string' && params.command.trim()
58
+ ? params.command.trim()
59
+ : (typeof payloadExtra.command === 'string' && payloadExtra.command.trim() ? payloadExtra.command.trim() : 'send_message');
60
+ const clientRequestId = typeof params.clientRequestId === 'string' && params.clientRequestId.trim()
61
+ ? params.clientRequestId.trim()
62
+ : buildClientRequestId();
63
+ const contractVersion = typeof params.contractVersion === 'string' && params.contractVersion.trim()
64
+ ? params.contractVersion.trim()
65
+ : 'flare.v1';
66
+ const lastTurnId = typeof params.lastTurnId === 'string' ? params.lastTurnId.trim() : '';
67
+ const token = typeof params.token === 'string' ? params.token.trim() : '';
68
+ const onTiming = typeof params.onTiming === 'function' ? params.onTiming : null;
69
+ const authProvider = (
70
+ params.auth
71
+ && typeof params.auth === 'object'
72
+ && !Array.isArray(params.auth)
73
+ ) ? params.auth : null;
74
+
75
+ const nowMs = Date.now();
76
+ onTiming?.({
77
+ point: 't_submit',
78
+ at_ms: nowMs,
79
+ request_id: clientRequestId,
80
+ session_id: String(sessionId || '').trim(),
81
+ });
82
+
83
+ const waitAuthReady = async () => {
84
+ if (!authProvider || typeof authProvider.ensureReady !== 'function') {
85
+ return {
86
+ authorizationToken: token,
87
+ authReadyAtMs: nowMs,
88
+ };
89
+ }
90
+ if (!this.authReadyPromise) {
91
+ this.authReadyPromise = Promise.resolve()
92
+ .then(() => authProvider.ensureReady())
93
+ .then((resolved) => {
94
+ this.authReadyAtMs = Date.now();
95
+ return resolved || null;
96
+ })
97
+ .finally(() => {
98
+ this.authReadyPromise = null;
99
+ });
100
+ }
101
+ const authResult = await this.authReadyPromise;
102
+ const tokenFromAuthResult = typeof authResult?.token === 'string' ? authResult.token.trim() : '';
103
+ const tokenFromProvider = typeof authProvider.getToken === 'function'
104
+ ? String(authProvider.getToken() || '').trim()
105
+ : '';
106
+ return {
107
+ authorizationToken: tokenFromAuthResult || tokenFromProvider || token,
108
+ authReadyAtMs: this.authReadyAtMs || Date.now(),
109
+ };
110
+ };
111
+
112
+ const currentRequestId = clientRequestId;
113
+ if (this.abortController) {
114
+ this.abortController.abort();
115
+ this.abortController = null;
116
+ }
51
117
  this.abortController = new AbortController();
118
+ this.activeRequestId = currentRequestId;
52
119
 
53
120
  try {
121
+ const authResolved = await waitAuthReady();
122
+ onTiming?.({
123
+ point: 't_auth_ready',
124
+ at_ms: authResolved.authReadyAtMs || Date.now(),
125
+ request_id: currentRequestId,
126
+ session_id: String(sessionId || '').trim(),
127
+ });
128
+ if (this.activeRequestId !== currentRequestId) {
129
+ return;
130
+ }
54
131
  const response = await fetch(url, {
55
132
  method: 'POST',
56
133
  headers: {
57
134
  'Content-Type': 'application/json',
135
+ ...(authResolved.authorizationToken ? { Authorization: `Bearer ${authResolved.authorizationToken}` } : {}),
58
136
  },
59
137
  body: JSON.stringify({
138
+ contract_version: contractVersion,
139
+ client_request_id: clientRequestId,
140
+ command,
141
+ last_turn_id: lastTurnId || null,
60
142
  message: content,
61
143
  session_id: sessionId,
62
144
  enabled_capabilities: enabledCapabilities,
63
145
  ...(modeKey ? { mode: modeKey } : {}),
64
146
  ...(manualModeKey ? { manual_mode: manualModeKey } : {}),
65
- ...(currentModeKey ? { current_mode: currentModeKey } : {}),
66
147
  payload: {
67
148
  message: content,
68
149
  ...(modeKey ? { mode: modeKey } : {}),
69
150
  ...(manualModeKey ? { manual_mode: manualModeKey } : {}),
70
- ...(currentModeKey ? { current_mode: currentModeKey } : {}),
71
151
  ...payloadExtra,
72
152
  },
73
153
  }),
74
154
  signal: this.abortController.signal,
75
155
  });
156
+ onTiming?.({
157
+ point: 't_stream_connected',
158
+ at_ms: Date.now(),
159
+ request_id: currentRequestId,
160
+ session_id: String(sessionId || '').trim(),
161
+ });
76
162
 
77
163
  if (!response.ok) {
78
164
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -108,13 +194,20 @@ export class SSEClient {
108
194
 
109
195
  try {
110
196
  const eventData = JSON.parse(data);
197
+ if (this.activeRequestId !== currentRequestId) {
198
+ return;
199
+ }
111
200
  onEvent?.({
112
201
  type: currentEvent,
113
202
  data: eventData,
114
203
  });
115
204
 
116
- if (currentEvent === 'complete') {
117
- onComplete?.();
205
+ if (currentEvent === 'complete' || currentEvent === 'done') {
206
+ await onComplete?.();
207
+ if (this.activeRequestId === currentRequestId) {
208
+ this.abortController = null;
209
+ this.activeRequestId = '';
210
+ }
118
211
  return;
119
212
  }
120
213
  } catch (error) {
@@ -123,13 +216,21 @@ export class SSEClient {
123
216
  }
124
217
  }
125
218
 
126
- onComplete?.();
219
+ await onComplete?.();
220
+ if (this.activeRequestId === currentRequestId) {
221
+ this.abortController = null;
222
+ this.activeRequestId = '';
223
+ }
127
224
  } catch (error) {
128
225
  if (error?.name === 'AbortError') {
129
226
  return;
130
227
  }
131
228
 
132
229
  onError?.(error instanceof Error ? error.message : '发送消息失败');
230
+ if (this.activeRequestId === currentRequestId) {
231
+ this.abortController = null;
232
+ this.activeRequestId = '';
233
+ }
133
234
  }
134
235
  }
135
236
 
@@ -138,5 +239,6 @@ export class SSEClient {
138
239
  this.abortController.abort();
139
240
  this.abortController = null;
140
241
  }
242
+ this.activeRequestId = '';
141
243
  }
142
244
  }
@@ -145,6 +145,49 @@ export const parseContent = (raw) => ({
145
145
  chunk: raw?.content ?? '',
146
146
  });
147
147
 
148
+ export const parseAck = (raw) => ({
149
+ trace_id: String(raw?.trace_id || '').trim(),
150
+ session_id: String(raw?.session_id || '').trim(),
151
+ request_id: String(raw?.request_id || '').trim(),
152
+ mode: String(raw?.mode || '').trim(),
153
+ ts: Number.isFinite(Number(raw?.ts)) ? Number(raw.ts) : null,
154
+ });
155
+
156
+ export const parsePhaseEvent = (raw) => ({
157
+ phase: String(raw?.phase || '').trim(),
158
+ label: String(raw?.label || '').trim(),
159
+ status: String(raw?.status || '').trim(),
160
+ progress: Number.isFinite(Number(raw?.progress)) ? Number(raw.progress) : null,
161
+ meta: raw?.meta && typeof raw.meta === 'object' && !Array.isArray(raw.meta) ? raw.meta : {},
162
+ ts: Number.isFinite(Number(raw?.ts)) ? Number(raw.ts) : null,
163
+ });
164
+
165
+ export const parsePatch = (raw) => ({
166
+ scope: String(raw?.scope || '').trim(),
167
+ payload: raw?.payload && typeof raw.payload === 'object' && !Array.isArray(raw.payload) ? raw.payload : {},
168
+ ts: Number.isFinite(Number(raw?.ts)) ? Number(raw.ts) : null,
169
+ });
170
+
171
+ export const parseTextDelta = (raw) => ({
172
+ channel: String(raw?.channel || 'assistant').trim() || 'assistant',
173
+ delta: String(raw?.delta || ''),
174
+ ts: Number.isFinite(Number(raw?.ts)) ? Number(raw.ts) : null,
175
+ });
176
+
177
+ export const parseTextReplace = (raw) => ({
178
+ channel: String(raw?.channel || 'assistant').trim() || 'assistant',
179
+ content: String(raw?.content || ''),
180
+ ts: Number.isFinite(Number(raw?.ts)) ? Number(raw.ts) : null,
181
+ });
182
+
183
+ export const parseDone = (raw) => ({
184
+ trace_id: String(raw?.trace_id || '').trim(),
185
+ session_id: String(raw?.session_id || '').trim(),
186
+ request_id: String(raw?.request_id || '').trim(),
187
+ status: String(raw?.status || 'done').trim() || 'done',
188
+ ts: Number.isFinite(Number(raw?.ts)) ? Number(raw.ts) : null,
189
+ });
190
+
148
191
  export const parseTrace = (raw) => ({
149
192
  trace: raw?.trace ?? raw?.detail ?? '',
150
193
  summary: raw?.summary ?? raw?.label ?? '',
@@ -169,9 +212,48 @@ function pickFirstArray(payload, keys) {
169
212
  return [];
170
213
  }
171
214
 
215
+ function parseGuidedSession(rawGuidedSession) {
216
+ const payload = (rawGuidedSession && typeof rawGuidedSession === 'object' && !Array.isArray(rawGuidedSession))
217
+ ? rawGuidedSession
218
+ : {};
219
+ const questionQueue = (payload.question_queue && typeof payload.question_queue === 'object' && !Array.isArray(payload.question_queue))
220
+ ? payload.question_queue
221
+ : {};
222
+ const summary = (payload.summary && typeof payload.summary === 'object' && !Array.isArray(payload.summary))
223
+ ? payload.summary
224
+ : {};
225
+ const decisionPoint = (payload.decision_point && typeof payload.decision_point === 'object' && !Array.isArray(payload.decision_point))
226
+ ? payload.decision_point
227
+ : {};
228
+ return {
229
+ state: payload?.state ?? '',
230
+ active: payload?.active === true,
231
+ current_question: payload?.current_question && typeof payload.current_question === 'object' && !Array.isArray(payload.current_question)
232
+ ? payload.current_question
233
+ : null,
234
+ question_queue: {
235
+ total: Number.isFinite(Number(questionQueue?.total)) ? Number(questionQueue.total) : 0,
236
+ answered: Number.isFinite(Number(questionQueue?.answered)) ? Number(questionQueue.answered) : 0,
237
+ remaining: Number.isFinite(Number(questionQueue?.remaining)) ? Number(questionQueue.remaining) : 0,
238
+ pending_fields: Array.isArray(questionQueue?.pending_fields) ? questionQueue.pending_fields : [],
239
+ },
240
+ summary: {
241
+ text: summary?.text ?? '',
242
+ ready: summary?.ready === true,
243
+ },
244
+ decision_point: {
245
+ active: decisionPoint?.active === true,
246
+ actions: Array.isArray(decisionPoint?.actions) ? decisionPoint.actions : [],
247
+ },
248
+ };
249
+ }
250
+
172
251
  export const parseFieldProgress = (raw) => {
173
252
  const payload = resolveModeEventPayload(raw);
174
253
  return {
254
+ intake_session_id: payload?.intake_session_id ?? '',
255
+ intake_session_state: payload?.intake_session_state ?? '',
256
+ intake_session_completed: payload?.intake_session_completed === true,
175
257
  flow_state: payload?.flow_state ?? payload?.collection_phase ?? '',
176
258
  fields: payload?.fields ?? {},
177
259
  sources: payload?.sources ?? {},
@@ -212,6 +294,15 @@ export const parseFieldProgress = (raw) => {
212
294
  : { current: 0, total: 0 },
213
295
  collection_phase: payload?.collection_phase ?? '',
214
296
  required_missing_count: Number.isFinite(payload?.required_missing_count) ? payload.required_missing_count : null,
297
+ required_coverage: Number.isFinite(Number(payload?.required_coverage)) ? Number(payload.required_coverage) : 0,
298
+ total_coverage: Number.isFinite(Number(payload?.total_coverage)) ? Number(payload.total_coverage) : 0,
299
+ analysis_entry_threshold: Number.isFinite(Number(payload?.analysis_entry_threshold)) ? Number(payload.analysis_entry_threshold) : 0.8,
300
+ analysis_entry_eligible: payload?.analysis_entry_eligible === true,
301
+ active_collecting: payload?.active_collecting === true,
302
+ ready_for_submit: payload?.ready_for_submit === true,
303
+ decision_point_active: payload?.decision_point_active === true,
304
+ has_active_question: payload?.has_active_question === true,
305
+ guided_session: parseGuidedSession(payload?.guided_session),
215
306
  };
216
307
  };
217
308
 
@@ -234,6 +325,9 @@ export const parseRequirementDraft = (raw) => {
234
325
  : {};
235
326
 
236
327
  return {
328
+ intake_session_id: payload?.intake_session_id ?? '',
329
+ intake_session_state: payload?.intake_session_state ?? '',
330
+ intake_session_completed: payload?.intake_session_completed === true,
237
331
  mode_key: payload?.mode_key ?? '',
238
332
  project_id: payload?.project_id ?? null,
239
333
  message_excerpt: payload?.message_excerpt ?? '',
@@ -251,6 +345,9 @@ export const parseNextActions = (raw) => {
251
345
  ? payload.actions
252
346
  : (Array.isArray(payload?.next_actions) ? payload.next_actions : []);
253
347
  return {
348
+ intake_session_id: payload?.intake_session_id ?? '',
349
+ intake_session_state: payload?.intake_session_state ?? '',
350
+ intake_session_completed: payload?.intake_session_completed === true,
254
351
  mode_key: payload?.mode_key ?? '',
255
352
  flow_state: payload?.flow_state ?? payload?.collection_phase ?? '',
256
353
  project_id: payload?.project_id ?? null,
@@ -282,6 +379,15 @@ export const parseNextActions = (raw) => {
282
379
  collection_phase: payload?.collection_phase ?? '',
283
380
  required_missing_count: Number.isFinite(payload?.required_missing_count) ? payload.required_missing_count : null,
284
381
  render_hint: payload?.render_hint ?? 'next_actions',
382
+ required_coverage: Number.isFinite(Number(payload?.required_coverage)) ? Number(payload.required_coverage) : 0,
383
+ total_coverage: Number.isFinite(Number(payload?.total_coverage)) ? Number(payload.total_coverage) : 0,
384
+ analysis_entry_threshold: Number.isFinite(Number(payload?.analysis_entry_threshold)) ? Number(payload.analysis_entry_threshold) : 0.8,
385
+ analysis_entry_eligible: payload?.analysis_entry_eligible === true,
386
+ active_collecting: payload?.active_collecting === true,
387
+ ready_for_submit: payload?.ready_for_submit === true,
388
+ decision_point_active: payload?.decision_point_active === true,
389
+ has_active_question: payload?.has_active_question === true,
390
+ guided_session: parseGuidedSession(payload?.guided_session),
285
391
  };
286
392
  };
287
393
 
@@ -473,8 +579,14 @@ export const parseKnowledgeCitation = (raw) => {
473
579
 
474
580
  export const parseOrchestrationStatus = (raw) => {
475
581
  const payload = resolveModeEventPayload(raw);
582
+ const activeWorkItem = payload?.active_work_item && typeof payload.active_work_item === 'object' && !Array.isArray(payload.active_work_item)
583
+ ? payload.active_work_item
584
+ : null;
476
585
  return {
477
586
  trace_id: payload?.trace_id ?? '',
587
+ intake_session_id: payload?.intake_session_id ?? '',
588
+ intake_session_state: payload?.intake_session_state ?? '',
589
+ intake_session_completed: payload?.intake_session_completed === true,
478
590
  current_stage: payload?.current_stage ?? '',
479
591
  detected_intent: payload?.detected_intent && typeof payload.detected_intent === 'object' && !Array.isArray(payload.detected_intent)
480
592
  ? payload.detected_intent
@@ -489,6 +601,21 @@ export const parseOrchestrationStatus = (raw) => {
489
601
  ? payload.next_action
490
602
  : {},
491
603
  next_actions: Array.isArray(payload?.next_actions) ? payload.next_actions : [],
604
+ active_collecting: payload?.active_collecting === true,
605
+ ready_for_submit: payload?.ready_for_submit === true,
606
+ required_coverage: Number.isFinite(Number(payload?.required_coverage)) ? Number(payload.required_coverage) : 0,
607
+ total_coverage: Number.isFinite(Number(payload?.total_coverage)) ? Number(payload.total_coverage) : 0,
608
+ analysis_entry_threshold: Number.isFinite(Number(payload?.analysis_entry_threshold)) ? Number(payload.analysis_entry_threshold) : 0.8,
609
+ analysis_entry_eligible: payload?.analysis_entry_eligible === true,
610
+ decision_point_active: payload?.decision_point_active === true,
611
+ has_active_question: payload?.has_active_question === true,
612
+ guided_session: parseGuidedSession(payload?.guided_session),
613
+ active_work_item: activeWorkItem,
614
+ active_fields: Array.isArray(payload?.active_fields) ? payload.active_fields : [],
615
+ hypotheses: Array.isArray(payload?.hypotheses) ? payload.hypotheses : [],
616
+ planner: payload?.planner && typeof payload.planner === 'object' && !Array.isArray(payload.planner)
617
+ ? payload.planner
618
+ : {},
492
619
  chooser: payload?.chooser && typeof payload.chooser === 'object' && !Array.isArray(payload.chooser)
493
620
  ? payload.chooser
494
621
  : null,
@@ -506,6 +633,26 @@ export const parseOrchestrationStatus = (raw) => {
506
633
  node_state: payload?.node_state ?? '',
507
634
  node_reason: payload?.node_reason ?? '',
508
635
  needs_user_choice: payload?.needs_user_choice === true,
636
+ conversation_intent_state: payload?.conversation_intent_state ?? '',
637
+ session_intent_stage: payload?.session_intent_stage ?? '',
638
+ intent_type: payload?.intent_type ?? '',
639
+ procurement_relevance: payload?.procurement_relevance === true,
640
+ consultative_procurement_qa: payload?.consultative_procurement_qa === true,
641
+ escalation_target: payload?.escalation_target ?? '',
642
+ intake_candidate: payload?.intake_candidate === true,
643
+ clarification_recommended: payload?.clarification_recommended === true,
644
+ analysis_ready: payload?.analysis_ready === true,
645
+ draft_seeded: payload?.draft_seeded === true,
646
+ missing_key_fields: Array.isArray(payload?.missing_key_fields) ? payload.missing_key_fields : [],
647
+ recommended_next_step: payload?.recommended_next_step ?? '',
648
+ intent_and_escalation_confidence: Number.isFinite(Number(payload?.intent_and_escalation_confidence))
649
+ ? Number(payload.intent_and_escalation_confidence)
650
+ : 0,
651
+ intent_and_escalation_reason: payload?.intent_and_escalation_reason ?? '',
652
+ intent_escalation_policy: payload?.intent_escalation_policy && typeof payload.intent_escalation_policy === 'object' && !Array.isArray(payload.intent_escalation_policy)
653
+ ? payload.intent_escalation_policy
654
+ : {},
655
+ intent_escalation_policy_source: payload?.intent_escalation_policy_source ?? '',
509
656
  degrade_reason: Array.isArray(payload?.degrade_reason) ? payload.degrade_reason : [],
510
657
  content_policy: payload?.content_policy && typeof payload.content_policy === 'object' && !Array.isArray(payload.content_policy)
511
658
  ? payload.content_policy
@@ -596,3 +743,125 @@ export const parseModeSwitchReason = (raw) => ({
596
743
  export const parseSSEError = (raw) => ({
597
744
  message: raw?.message ?? '发生错误',
598
745
  });
746
+
747
+ export const PATCH_SCOPE_AUTHORITATIVE = Object.freeze([
748
+ 'session',
749
+ 'assistant_reply',
750
+ 'primary_flow',
751
+ 'checkpoint',
752
+ 'intent_state',
753
+ 'question',
754
+ 'confirmed_fields',
755
+ 'missing_fields',
756
+ 'next_actions',
757
+ 'analysis',
758
+ 'observation',
759
+ 'capabilities.field_retrieval',
760
+ 'errors',
761
+ ]);
762
+
763
+ export const PATCH_SCOPE_LEGACY_COMPAT = Object.freeze([
764
+ 'current_question',
765
+ 'recommendation_summary',
766
+ ]);
767
+
768
+ const PATCH_SCOPE_ALLOWLIST = new Set([
769
+ ...PATCH_SCOPE_AUTHORITATIVE,
770
+ ...PATCH_SCOPE_LEGACY_COMPAT,
771
+ ]);
772
+
773
+ function getByScopePath(payload, scope) {
774
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
775
+ return undefined;
776
+ }
777
+ const segments = String(scope || '').split('.').filter(Boolean);
778
+ if (!segments.length) {
779
+ return undefined;
780
+ }
781
+ let current = payload;
782
+ for (const segment of segments) {
783
+ if (!current || typeof current !== 'object' || Array.isArray(current) || !(segment in current)) {
784
+ return undefined;
785
+ }
786
+ current = current[segment];
787
+ }
788
+ return current;
789
+ }
790
+
791
+ export const parsePatchEvent = (raw) => {
792
+ const envelope = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw : {};
793
+ const patchScope = Array.isArray(envelope.patch_scope)
794
+ ? envelope.patch_scope.map((item) => String(item || '').trim()).filter(Boolean)
795
+ : [];
796
+ const payload = (envelope.payload && typeof envelope.payload === 'object' && !Array.isArray(envelope.payload))
797
+ ? envelope.payload
798
+ : {};
799
+ const hasPayload = Object.keys(payload).length > 0;
800
+ const hasUnknownScope = patchScope.some((item) => !(PATCH_SCOPE_ALLOWLIST.has(item) || item.startsWith('confirmed_fields.')));
801
+ const hasScopePayloadMismatch = patchScope.some((scope) => {
802
+ const direct = payload[scope];
803
+ const byPath = getByScopePath(payload, scope);
804
+ const lastSegment = String(scope).split('.').slice(-1)[0];
805
+ const leaf = payload[lastSegment];
806
+ return direct === undefined && byPath === undefined && leaf === undefined;
807
+ });
808
+ const legacyPatchScope = patchScope.filter((scope) => PATCH_SCOPE_LEGACY_COMPAT.includes(scope));
809
+ const authoritativePatchScope = patchScope.filter(
810
+ (scope) => PATCH_SCOPE_AUTHORITATIVE.includes(scope) || scope.startsWith('confirmed_fields.')
811
+ );
812
+ const invalidReason = (() => {
813
+ if (envelope.message_type !== 'patch_event') {
814
+ return 'invalid_message_type';
815
+ }
816
+ if (!['ack', 'patch', 'error', 'final'].includes(String(envelope.event_type || '').trim())) {
817
+ return 'invalid_event_type';
818
+ }
819
+ if (hasPayload && patchScope.length === 0) {
820
+ return 'payload_without_patch_scope';
821
+ }
822
+ if (hasUnknownScope) {
823
+ return 'unknown_patch_scope';
824
+ }
825
+ if (patchScope.length > 0 && hasScopePayloadMismatch) {
826
+ return 'patch_scope_payload_mismatch';
827
+ }
828
+ return '';
829
+ })();
830
+ return {
831
+ contract_version: String(envelope.contract_version || 'flare.v1'),
832
+ message_type: 'patch_event',
833
+ event_type: String(envelope.event_type || '').trim(),
834
+ sequence: Number.isFinite(Number(envelope.sequence)) ? Number(envelope.sequence) : 0,
835
+ trace_id: String(envelope.trace_id || '').trim(),
836
+ client_request_id: String(envelope.client_request_id || '').trim(),
837
+ intake_session_id: String(
838
+ payload?.primary_flow?.intake_session_id
839
+ || payload?.checkpoint?.intake_session_id
840
+ || payload?.next_actions?.intake_session_id
841
+ || payload?.question?.intake_session_id
842
+ || payload?.intake_session_id
843
+ || ''
844
+ ).trim(),
845
+ intake_session_state: String(
846
+ payload?.primary_flow?.intake_session_state
847
+ || payload?.next_actions?.intake_session_state
848
+ || payload?.intake_session_state
849
+ || ''
850
+ ).trim(),
851
+ intake_session_completed: Boolean(
852
+ payload?.primary_flow?.intake_session_completed
853
+ || payload?.next_actions?.intake_session_completed
854
+ || payload?.intake_session_completed
855
+ ),
856
+ session: (
857
+ envelope.session && typeof envelope.session === 'object' && !Array.isArray(envelope.session)
858
+ ) ? envelope.session : { session_id: '', turn_id: '' },
859
+ patch_scope: patchScope,
860
+ authoritative_patch_scope: authoritativePatchScope,
861
+ legacy_patch_scope: legacyPatchScope,
862
+ payload,
863
+ final: envelope.final === true,
864
+ invalid: Boolean(invalidReason),
865
+ invalid_reason: invalidReason,
866
+ };
867
+ };
@@ -1,10 +1,16 @@
1
- import { useRef, useState, useCallback } from 'react';
1
+ import { useRef, useState, useCallback, useEffect } from 'react';
2
2
  import { SSEClient } from './sse-client.js';
3
3
  import {
4
+ parseAck,
4
5
  parseAgentStatus,
5
6
  parseThinkingTrace,
6
7
  parseExecutionTrace,
7
8
  parseContent,
9
+ parseDone,
10
+ parsePatch,
11
+ parsePhaseEvent,
12
+ parseTextDelta,
13
+ parseTextReplace,
8
14
  parseTrace,
9
15
  parseFieldProgress,
10
16
  parseDoc,
@@ -31,6 +37,7 @@ import {
31
37
  parseCanvasState,
32
38
  parseCanvasRevision,
33
39
  parsePlanBlock,
40
+ parsePatchEvent,
34
41
  parseSSEError,
35
42
  } from './sse-events.js';
36
43
 
@@ -52,19 +59,28 @@ export function useSSEStream(sessionId) {
52
59
  const lastMessageRef = useRef({ content: '', handlers: {} });
53
60
 
54
61
  const send = useCallback(async (content, handlers = {}, options = {}) => {
55
- const resolvedSessionId = options.sessionIdOverride || sessionId;
56
- if (!resolvedSessionId || !content.trim()) return;
62
+ const hasSessionOverride = Object.prototype.hasOwnProperty.call(options, 'sessionIdOverride');
63
+ const resolvedSessionId = hasSessionOverride ? options.sessionIdOverride : sessionId;
64
+ if (!content.trim()) return;
57
65
 
58
66
  lastMessageRef.current = { content, handlers, options };
59
67
  setError(null);
60
68
  accumulatedRef.current = '';
61
69
  setLoading(true);
70
+ let latestSessionId = String(resolvedSessionId || '').trim();
62
71
 
63
72
  const {
73
+ onAck,
64
74
  onAgentStatus,
65
75
  onThinkingTrace,
66
76
  onExecutionTrace,
67
77
  onContent,
78
+ onTextReplace,
79
+ onPhaseStart,
80
+ onPhaseUpdate,
81
+ onPhaseEnd,
82
+ onPatch,
83
+ onDone,
68
84
  onTrace,
69
85
  onWorkspaceActivation,
70
86
  onUICards,
@@ -91,12 +107,28 @@ export function useSSEStream(sessionId) {
91
107
  onPlanBlock,
92
108
  onDoc,
93
109
  onCapabilitySuggestion,
110
+ onPatchEvent,
94
111
  onError,
95
112
  onComplete,
96
113
  } = handlers;
97
114
 
98
115
  const eventHandler = (event) => {
99
116
  switch (event.type) {
117
+ case 'ack':
118
+ onAck?.(parseAck(event.data));
119
+ break;
120
+ case 'phase.start':
121
+ onPhaseStart?.(parsePhaseEvent(event.data));
122
+ break;
123
+ case 'phase.update':
124
+ onPhaseUpdate?.(parsePhaseEvent(event.data));
125
+ break;
126
+ case 'phase.end':
127
+ onPhaseEnd?.(parsePhaseEvent(event.data));
128
+ break;
129
+ case 'patch':
130
+ onPatch?.(parsePatch(event.data));
131
+ break;
100
132
  case 'agent_status':
101
133
  onAgentStatus?.(parseAgentStatus(event.data));
102
134
  break;
@@ -116,6 +148,25 @@ export function useSSEStream(sessionId) {
116
148
  onContent?.(chunk);
117
149
  break;
118
150
  }
151
+ case 'text.delta': {
152
+ const parsed = parseTextDelta(event.data);
153
+ if (parsed.channel === 'assistant' && parsed.delta) {
154
+ accumulatedRef.current += parsed.delta;
155
+ onContent?.(parsed.delta);
156
+ }
157
+ break;
158
+ }
159
+ case 'text.replace': {
160
+ const parsed = parseTextReplace(event.data);
161
+ if (parsed.channel === 'assistant') {
162
+ accumulatedRef.current = parsed.content;
163
+ onTextReplace?.(parsed.content);
164
+ }
165
+ break;
166
+ }
167
+ case 'done':
168
+ onDone?.(parseDone(event.data));
169
+ break;
119
170
  case 'trace':
120
171
  onTrace?.(parseTrace(event.data));
121
172
  break;
@@ -194,6 +245,16 @@ export function useSSEStream(sessionId) {
194
245
  case 'capability_suggestion':
195
246
  onCapabilitySuggestion?.(parseCapabilitySuggestion(event.data));
196
247
  break;
248
+ case 'patch_event':
249
+ {
250
+ const parsedPatchEvent = parsePatchEvent(event.data);
251
+ const patchSessionId = String(parsedPatchEvent?.session?.session_id || '').trim();
252
+ if (patchSessionId) {
253
+ latestSessionId = patchSessionId;
254
+ }
255
+ onPatchEvent?.(parsedPatchEvent);
256
+ }
257
+ break;
197
258
  case 'error':
198
259
  onError?.(parseSSEError(event.data));
199
260
  break;
@@ -202,9 +263,11 @@ export function useSSEStream(sessionId) {
202
263
  }
203
264
  };
204
265
 
205
- const completeHandler = () => {
266
+ const completeHandler = async () => {
206
267
  setLoading(false);
207
- onComplete?.(accumulatedRef.current);
268
+ await onComplete?.(accumulatedRef.current, {
269
+ sessionId: latestSessionId || null,
270
+ });
208
271
  accumulatedRef.current = '';
209
272
  };
210
273
 
@@ -217,12 +280,15 @@ export function useSSEStream(sessionId) {
217
280
  };
218
281
 
219
282
  const requestOptions = {
220
- sessionId: resolvedSessionId,
283
+ sessionId: String(resolvedSessionId || '').trim() || null,
221
284
  content,
222
285
  enabledCapabilities: Array.isArray(options.enabledCapabilities) ? options.enabledCapabilities : [],
223
286
  modeKey: typeof options.modeKey === 'string' ? options.modeKey : '',
224
287
  manualModeKey: typeof options.manualModeKey === 'string' ? options.manualModeKey : '',
225
- currentModeKey: typeof options.currentModeKey === 'string' ? options.currentModeKey : '',
288
+ command: typeof options.command === 'string' ? options.command : '',
289
+ clientRequestId: typeof options.clientRequestId === 'string' ? options.clientRequestId : '',
290
+ contractVersion: typeof options.contractVersion === 'string' ? options.contractVersion : '',
291
+ lastTurnId: typeof options.lastTurnId === 'string' ? options.lastTurnId : '',
226
292
  payloadExtra: (
227
293
  options.payloadExtra
228
294
  && typeof options.payloadExtra === 'object'
@@ -233,6 +299,13 @@ export function useSSEStream(sessionId) {
233
299
  endpoint: options.endpoint,
234
300
  baseUrl: options.baseUrl,
235
301
  url: options.url,
302
+ token: typeof options.token === 'string' ? options.token : '',
303
+ auth: (
304
+ options.auth
305
+ && typeof options.auth === 'object'
306
+ && !Array.isArray(options.auth)
307
+ ) ? options.auth : undefined,
308
+ onTiming: typeof options.onTiming === 'function' ? options.onTiming : undefined,
236
309
  };
237
310
 
238
311
  if (!requestOptions.enabledCapabilities.length) {
@@ -240,12 +313,18 @@ export function useSSEStream(sessionId) {
240
313
  }
241
314
  if (!requestOptions.modeKey) delete requestOptions.modeKey;
242
315
  if (!requestOptions.manualModeKey) delete requestOptions.manualModeKey;
243
- if (!requestOptions.currentModeKey) delete requestOptions.currentModeKey;
316
+ if (!requestOptions.command) delete requestOptions.command;
317
+ if (!requestOptions.clientRequestId) delete requestOptions.clientRequestId;
318
+ if (!requestOptions.contractVersion) delete requestOptions.contractVersion;
319
+ if (!requestOptions.lastTurnId) delete requestOptions.lastTurnId;
244
320
  if (!Object.keys(requestOptions.payloadExtra || {}).length) delete requestOptions.payloadExtra;
245
321
 
246
322
  if (!requestOptions.endpoint) delete requestOptions.endpoint;
247
323
  if (!requestOptions.baseUrl) delete requestOptions.baseUrl;
248
324
  if (!requestOptions.url) delete requestOptions.url;
325
+ if (!requestOptions.token) delete requestOptions.token;
326
+ if (!requestOptions.auth) delete requestOptions.auth;
327
+ if (!requestOptions.onTiming) delete requestOptions.onTiming;
249
328
 
250
329
  await clientRef.current.sendMessage(
251
330
  requestOptions,
@@ -267,6 +346,10 @@ export function useSSEStream(sessionId) {
267
346
  accumulatedRef.current = '';
268
347
  }, []);
269
348
 
349
+ useEffect(() => () => {
350
+ clientRef.current.abort();
351
+ }, []);
352
+
270
353
  return { send, loading, error, retry, abort };
271
354
  }
272
355