flare-chat-core 0.2.1 → 0.2.2

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 (120) hide show
  1. package/README.md +28 -0
  2. package/docs/CAPABILITY-INVENTORY.md +42 -0
  3. package/docs/CHAT-CORE-BOUNDARY.md +47 -0
  4. package/docs/CORE-APP-REALIGNMENT-WORKLOAD-2026-04-18.md +86 -0
  5. package/docs/SSOT-CHAT-CORE-BOUNDARY.md +73 -0
  6. package/docs/SSOT-CHAT-CORE-DATAFLOW.md +97 -0
  7. package/index.html +12 -0
  8. package/package.json +24 -2
  9. package/src/adapters/index.js +6 -0
  10. package/src/adapters/message-api.adapter.js +59 -0
  11. package/src/adapters/session-api.adapter.js +133 -0
  12. package/src/adapters/session-message-api.http.js +161 -0
  13. package/src/adapters/session-message-api.js +34 -0
  14. package/src/adapters/session-message-api.normalize-source-record.test.mjs +180 -0
  15. package/src/adapters/session-message-api.normalizers.js +153 -0
  16. package/src/adapters/source-api.adapter.js +135 -0
  17. package/src/adapters/sse-client.js +244 -0
  18. package/src/adapters/sse-event-dispatcher.js +121 -0
  19. package/src/app/App.jsx +11 -0
  20. package/src/app/AppProviders.jsx +12 -0
  21. package/src/app/ChatWorkspaceScreen.jsx +33 -0
  22. package/src/app/WorkspaceLayout.jsx +125 -0
  23. package/src/app/components/AppCanvasPanel.jsx +64 -0
  24. package/src/app/components/TriggerThresholdPopoverContent.jsx +122 -0
  25. package/src/app/components/WorkspaceBodySection.jsx +109 -0
  26. package/src/app/components/WorkspaceMainPane.jsx +113 -0
  27. package/src/app/components/WorkspaceSessionPane.jsx +48 -0
  28. package/src/app/components/WorkspaceTopBarSection.jsx +65 -0
  29. package/src/app/core-chat-entry/ComposerSectionNode.jsx +241 -0
  30. package/src/app/core-chat-entry/attachmentSendRefs.js +154 -0
  31. package/src/app/core-chat-entry/attachmentSendRefs.test.mjs +101 -0
  32. package/src/app/core-chat-entry/composerActionRouter.js +26 -0
  33. package/src/app/core-chat-entry/constants.js +108 -0
  34. package/src/app/core-chat-entry/selectors.js +28 -0
  35. package/src/app/core-chat-entry/useAppActionErrorGuards.js +68 -0
  36. package/src/app/core-chat-entry/useChatCorePipelines.js +110 -0
  37. package/src/app/core-chat-entry/useComposerModeSuggestion.js +89 -0
  38. package/src/app/core-chat-entry/useDevCapabilityStatusNote.js +22 -0
  39. package/src/app/core-chat-entry/useProjectNameEditing.js +41 -0
  40. package/src/app/core-chat-entry/useProjectSourceUpload.js +341 -0
  41. package/src/app/core-chat-entry/useRealApiReadinessGate.js +103 -0
  42. package/src/app/core-chat-entry/useUnavailableActionError.js +29 -0
  43. package/src/app/core-chat-entry/useWorkspaceCanvasController.jsx +177 -0
  44. package/src/app/core-chat-entry/useWorkspaceCanvasProjection.jsx +171 -0
  45. package/src/app/core-chat-entry/useWorkspaceComposerController.jsx +199 -0
  46. package/src/app/core-chat-entry/useWorkspaceController.jsx +226 -0
  47. package/src/app/core-chat-entry/useWorkspacePanels.js +55 -0
  48. package/src/app/hooks/useComposerAttachmentSync.js +223 -0
  49. package/src/app/hooks/useComposerChooserHandlers.js +52 -0
  50. package/src/app/hooks/useSendWithContextRefs.js +140 -0
  51. package/src/app/hooks/useSendWithContextRefs.test.mjs +29 -0
  52. package/src/app/hooks/useUserThresholdProfile.js +121 -0
  53. package/src/app/index.js +1 -0
  54. package/src/app/selectors/assistantTextSelector.js +73 -0
  55. package/src/app/selectors/canvasEvidenceSummarySelector.js +28 -0
  56. package/src/app/selectors/canvasReportTemplateSelector.js +28 -0
  57. package/src/app/selectors/canvasTabsSelector.js +58 -0
  58. package/src/app/selectors/evidenceProjectionSelector.js +175 -0
  59. package/src/app/selectors/evidenceProjectionSelector.test.mjs +107 -0
  60. package/src/app/selectors/modeSuggestionSelector.js +50 -0
  61. package/src/chat-core/app/mockRuntime.js +291 -0
  62. package/src/chat-core/app/useAppStream.js +187 -0
  63. package/src/chat-core/app/useAppStream.refs.test.mjs +44 -0
  64. package/src/chat-core/app/useAppStream.request-body.test.mjs +116 -0
  65. package/src/chat-core/app/useCoreChatApp.js +115 -0
  66. package/src/chat-core/facade/useBasicConversationFacade.js +280 -0
  67. package/src/chat-core/index.js +9 -1
  68. package/src/chat-core/messages/buildTimelineItems.analysis-route.test.mjs +36 -0
  69. package/src/chat-core/messages/buildTimelineItems.js +170 -12
  70. package/src/chat-core/messages/buildTimelineItems.knowledge-citation.test.mjs +183 -0
  71. package/src/chat-core/messages/contextUsageDefaults.js +3 -0
  72. package/src/chat-core/messages/contextUsageViewModel.js +147 -0
  73. package/src/chat-core/messages/contextUsageViewModel.test.mjs +74 -0
  74. package/src/chat-core/messages/useContextUsageViewModel.js +41 -0
  75. package/src/chat-core/orchestration/useBasicSendHandler.js +55 -0
  76. package/src/chat-core/pipelines/build-action-request.js +46 -0
  77. package/src/chat-core/pipelines/build-stream-request.js +74 -0
  78. package/src/chat-core/pipelines/entity-extraction.js +159 -0
  79. package/src/chat-core/pipelines/preprocess-message.js +16 -0
  80. package/src/chat-core/pipelines/stream-persist-utils.js +32 -0
  81. package/src/chat-core/pipelines/transport/send-mock-stream.js +86 -0
  82. package/src/chat-core/pipelines/transport/send-real-stream.js +330 -0
  83. package/src/chat-core/pipelines/transport/send-real-stream.test.mjs +27 -0
  84. package/src/chat-core/pipelines/transport/send-sourcing-search.js +86 -0
  85. package/src/chat-core/pipelines/transport/send-sourcing-search.test.mjs +14 -0
  86. package/src/chat-core/pipelines/transport/sourcing-response-templates.js +55 -0
  87. package/src/chat-core/pipelines/transport/sourcing-search-api.js +155 -0
  88. package/src/chat-core/runtime/runtimeMode.js +69 -0
  89. package/src/chat-core/session/chatSessionActionTypes.js +24 -0
  90. package/src/chat-core/session/chatSessionReducer.js +352 -0
  91. package/src/chat-core/session/chatSessionReducer.streaming-done.test.mjs +39 -0
  92. package/src/chat-core/session/index.js +2 -0
  93. package/src/chat-core/session/sessionActionsMessages.js +44 -0
  94. package/src/chat-core/session/sessionActionsSessionCrud.js +131 -0
  95. package/src/chat-core/session/sessionActionsStreaming.js +80 -0
  96. package/src/chat-core/session/sessionActionsUiState.js +51 -0
  97. package/src/chat-core/session/useChatSessionReducer.js +62 -455
  98. package/src/chat-core/session/useSessionListController.js +67 -0
  99. package/src/chat-core/stream/sse-client.js +1 -244
  100. package/src/chat-core/stream/sse-event-dispatcher.js +1 -0
  101. package/src/chat-core/stream/sse-events.js +1 -867
  102. package/src/chat-core/stream/useSSEStream.js +1 -356
  103. package/src/chat-core/stream/useStreamSendController.js +46 -0
  104. package/src/contracts/context-ssot.js +47 -0
  105. package/src/contracts/index.js +1 -0
  106. package/src/contracts/sse-events/base-parsers.js +79 -0
  107. package/src/contracts/sse-events/domain-parsers.js +3 -0
  108. package/src/contracts/sse-events/internal-normalizers.js +143 -0
  109. package/src/contracts/sse-events/parsers-intake.js +235 -0
  110. package/src/contracts/sse-events/parsers-runtime.js +37 -0
  111. package/src/contracts/sse-events/parsers-sourcing.js +179 -0
  112. package/src/contracts/sse-events/patch-event-parser.js +121 -0
  113. package/src/contracts/sse-events/runtime-parsers.js +79 -0
  114. package/src/contracts/sse-events.js +4 -0
  115. package/src/index.js +5 -0
  116. package/src/main.jsx +28 -0
  117. package/src/orchestration/index.js +6 -0
  118. package/src/orchestration/useSSEStream.js +221 -0
  119. package/src/state/index.js +4 -0
  120. package/vite.config.js +36 -0
@@ -0,0 +1,183 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildTimelineItems } from './buildTimelineItems.js';
4
+
5
+ test('buildTimelineItems appends knowledge_citation artifact from streaming state', () => {
6
+ const timeline = buildTimelineItems({
7
+ messages: [
8
+ {
9
+ message_id: 'm1',
10
+ role: 'user',
11
+ content: 'hello',
12
+ created_at: '2026-04-19T00:00:00Z',
13
+ },
14
+ ],
15
+ streaming: {
16
+ content: '',
17
+ knowledgeCitation: {
18
+ run_id: 'retrieval-1',
19
+ citations: [{
20
+ citation_id: 'cite_1',
21
+ source_id: 'src-1',
22
+ matched_text: '公司20周年庆典礼品采购',
23
+ score: 0.23,
24
+ }],
25
+ },
26
+ roundKey: 'rk-1',
27
+ },
28
+ });
29
+
30
+ const citationItem = timeline.items.find((item) => item.type === 'knowledge_citation');
31
+ assert.ok(citationItem);
32
+ assert.equal(citationItem.id, 'knowledge-citation-retrieval-1');
33
+ assert.equal(citationItem.payload.run_id, 'retrieval-1');
34
+ assert.equal(citationItem.payload.citations[0].source_id, 'src-1');
35
+ });
36
+
37
+ test('buildTimelineItems skips knowledge_citation artifact when citations are non-hit noise', () => {
38
+ const timeline = buildTimelineItems({
39
+ messages: [
40
+ {
41
+ message_id: 'm1',
42
+ role: 'user',
43
+ content: 'hello',
44
+ created_at: '2026-04-19T00:00:00Z',
45
+ },
46
+ ],
47
+ streaming: {
48
+ content: '',
49
+ knowledgeCitation: {
50
+ run_id: 'retrieval-noise',
51
+ citations: [
52
+ {
53
+ citation_id: 'cite_noise',
54
+ source_id: 'src-noise',
55
+ score: 0.0001,
56
+ snippet: '周年庆',
57
+ },
58
+ ],
59
+ },
60
+ roundKey: 'rk-1',
61
+ },
62
+ });
63
+
64
+ const citationItem = timeline.items.find((item) => item.type === 'knowledge_citation');
65
+ assert.equal(citationItem, undefined);
66
+ });
67
+
68
+ test('buildTimelineItems appends knowledge_search artifact from streaming state', () => {
69
+ const timeline = buildTimelineItems({
70
+ messages: [
71
+ {
72
+ message_id: 'm1',
73
+ role: 'user',
74
+ content: 'hello',
75
+ created_at: '2026-04-19T00:00:00Z',
76
+ },
77
+ ],
78
+ streaming: {
79
+ content: '',
80
+ knowledgeSearch: {
81
+ run_id: 'search-1',
82
+ query: '上海大型erp供应商',
83
+ results: [{ id: 'vendor-1', name: 'Vendor A' }],
84
+ },
85
+ roundKey: 'rk-1',
86
+ },
87
+ });
88
+
89
+ const searchItem = timeline.items.find((item) => item.type === 'knowledge_search');
90
+ assert.ok(searchItem);
91
+ assert.equal(searchItem.id, 'knowledge-search-search-1');
92
+ assert.equal(searchItem.payload.query, '上海大型erp供应商');
93
+ });
94
+
95
+ test('buildTimelineItems appends sourcing_candidates artifact from streaming state', () => {
96
+ const timeline = buildTimelineItems({
97
+ messages: [
98
+ {
99
+ message_id: 'm1',
100
+ role: 'user',
101
+ content: 'hello',
102
+ created_at: '2026-04-19T00:00:00Z',
103
+ },
104
+ ],
105
+ streaming: {
106
+ content: '',
107
+ sourcingCandidates: {
108
+ run_id: 'source-1',
109
+ candidates: [{ supplier_id: 'sup-1', supplier_name: 'Vendor A' }],
110
+ },
111
+ roundKey: 'rk-1',
112
+ },
113
+ });
114
+
115
+ const sourcingItem = timeline.items.find((item) => item.type === 'sourcing_candidates');
116
+ assert.ok(sourcingItem);
117
+ assert.equal(sourcingItem.id, 'sourcing-candidates-source-1');
118
+ });
119
+
120
+ test('buildTimelineItems keeps executionCards on thinking item for step-by-step display', () => {
121
+ const timeline = buildTimelineItems({
122
+ messages: [
123
+ {
124
+ message_id: 'm1',
125
+ role: 'user',
126
+ content: 'hello',
127
+ created_at: '2026-04-19T00:00:00Z',
128
+ },
129
+ ],
130
+ executionCards: [
131
+ { step_id: 'context_binding', label: '上下文绑定', detail: '绑定 2 个来源', status: 'completed' },
132
+ ],
133
+ streaming: {
134
+ content: '',
135
+ executionTrace: { step_id: 'llm', label: '生成回复', status: 'running' },
136
+ roundKey: 'rk-2',
137
+ },
138
+ });
139
+
140
+ const thinkingItem = timeline.items.find((item) => item.type === 'thinking');
141
+ assert.ok(thinkingItem);
142
+ assert.equal(Array.isArray(thinkingItem.executionCards), true);
143
+ assert.equal(thinkingItem.executionCards.length, 1);
144
+ assert.equal(thinkingItem.executionCards[0].step_id, 'context_binding');
145
+ });
146
+
147
+ test('buildTimelineItems keeps only latest assistant historical thinking item', () => {
148
+ const timeline = buildTimelineItems({
149
+ messages: [
150
+ {
151
+ message_id: 'u1',
152
+ role: 'user',
153
+ content: 'hello',
154
+ created_at: '2026-04-19T00:00:00Z',
155
+ },
156
+ {
157
+ message_id: 'a1',
158
+ role: 'assistant',
159
+ content: 'first answer',
160
+ created_at: '2026-04-19T00:00:01Z',
161
+ execution_trace: { step_id: 'knowledge_search', status: 'completed' },
162
+ },
163
+ {
164
+ message_id: 'u2',
165
+ role: 'user',
166
+ content: 'next',
167
+ created_at: '2026-04-19T00:00:02Z',
168
+ },
169
+ {
170
+ message_id: 'a2',
171
+ role: 'assistant',
172
+ content: 'second answer',
173
+ created_at: '2026-04-19T00:00:03Z',
174
+ execution_trace: { step_id: 'knowledge_search', status: 'completed' },
175
+ },
176
+ ],
177
+ streaming: {},
178
+ });
179
+
180
+ const thinkingItems = timeline.items.filter((item) => item.type === 'thinking');
181
+ assert.equal(thinkingItems.length, 1);
182
+ assert.equal(thinkingItems[0].id, 'thinking-a2');
183
+ });
@@ -0,0 +1,3 @@
1
+ export const CONTEXT_USAGE_WINDOW_TOKEN_LIMIT = 258000;
2
+ export const CONTEXT_USAGE_TOKEN_ESTIMATE_DIVISOR = 2.4;
3
+ export const CONTEXT_USAGE_BASE_TOKENS_PER_MESSAGE = 32;
@@ -0,0 +1,147 @@
1
+ function coercePositiveInteger(value) {
2
+ const parsed = Number(value);
3
+ if (!Number.isFinite(parsed)) {
4
+ return 0;
5
+ }
6
+ return Math.max(0, Math.round(parsed));
7
+ }
8
+
9
+ function extractTimelineMessageText(item) {
10
+ if (!item || typeof item !== 'object') {
11
+ return '';
12
+ }
13
+ const candidates = [item.content, item.text, item.message, item.value];
14
+ for (const candidate of candidates) {
15
+ const normalized = String(candidate || '').replace(/\s+/g, ' ').trim();
16
+ if (normalized) {
17
+ return normalized;
18
+ }
19
+ }
20
+ return '';
21
+ }
22
+
23
+ function extractContextUsageFromTrace(trace) {
24
+ if (!trace || typeof trace !== 'object' || Array.isArray(trace)) {
25
+ return null;
26
+ }
27
+ if (String(trace.step_id || '').trim() !== 'context_usage') {
28
+ return null;
29
+ }
30
+ const detail = String(trace.detail || '').trim();
31
+ if (!detail) {
32
+ return null;
33
+ }
34
+ const modeMatch = detail.match(/mode=([^;/]+)\/([^;]+)/);
35
+ const profileMatch = detail.match(/profile=([^;]+)/);
36
+ const tokenMatch = detail.match(/tokens≈(\d+)/);
37
+ return {
38
+ mode_key: modeMatch ? String(modeMatch[1] || '').trim() : '',
39
+ mode_state: modeMatch ? String(modeMatch[2] || '').trim() : '',
40
+ profile: profileMatch ? String(profileMatch[1] || '').trim() : '',
41
+ token_estimate: {
42
+ total_prompt_tokens_estimate: tokenMatch ? coercePositiveInteger(tokenMatch[1]) : 0,
43
+ },
44
+ };
45
+ }
46
+
47
+ function resolveLatestTimelineContextUsage(items) {
48
+ const timelineItems = Array.isArray(items) ? items : [];
49
+ for (let index = timelineItems.length - 1; index >= 0; index -= 1) {
50
+ const item = timelineItems[index];
51
+ if (String(item?.type || '').trim() === 'message' && String(item?.role || '').trim() === 'assistant') {
52
+ const usage = item?.contextUsage;
53
+ if (usage && typeof usage === 'object' && !Array.isArray(usage)) {
54
+ return { source: 'backend', usage };
55
+ }
56
+ }
57
+ if (String(item?.type || '').trim() === 'thinking') {
58
+ const usage = extractContextUsageFromTrace(item?.executionTrace);
59
+ if (usage) {
60
+ return { source: 'stream_trace', usage };
61
+ }
62
+ }
63
+ }
64
+ return { source: 'estimate', usage: null };
65
+ }
66
+
67
+ function buildLayerSummaryLabel(layers) {
68
+ if (!Array.isArray(layers) || layers.length === 0) {
69
+ return '';
70
+ }
71
+ const normalizedLayers = layers.filter((layer) => layer && typeof layer === 'object');
72
+ if (normalizedLayers.length === 0) {
73
+ return '';
74
+ }
75
+ const includedLayers = normalizedLayers.filter((layer) => layer.included !== false).length;
76
+ return `layers ${includedLayers}/${normalizedLayers.length}`;
77
+ }
78
+
79
+ function buildBudgetStatusLabel({ tokens, limit }) {
80
+ if (!Number.isFinite(tokens) || !Number.isFinite(limit) || limit <= 0) {
81
+ return '';
82
+ }
83
+ const ratio = tokens / limit;
84
+ if (ratio >= 1) {
85
+ return '超限';
86
+ }
87
+ if (ratio >= 0.85) {
88
+ return '临界';
89
+ }
90
+ if (ratio >= 0.6) {
91
+ return '偏高';
92
+ }
93
+ return '正常';
94
+ }
95
+
96
+ export function buildContextUsageViewModel({
97
+ timelineItems,
98
+ fallbackTokenLimit,
99
+ tokenEstimateDivisor = 2.4,
100
+ baseTokensPerMessage = 32,
101
+ }) {
102
+ const normalizedTimelineItems = Array.isArray(timelineItems) ? timelineItems : [];
103
+ const messageItems = normalizedTimelineItems.filter((item) => String(item?.type || '').trim() === 'message');
104
+ const messageTexts = messageItems.map((item) => extractTimelineMessageText(item)).filter(Boolean);
105
+ const messageCount = messageTexts.length;
106
+ const textCharCount = messageTexts.reduce((total, text) => total + text.length, 0);
107
+ const { source, usage } = resolveLatestTimelineContextUsage(timelineItems);
108
+ const sourceLabel = source === 'backend'
109
+ ? '后端'
110
+ : (source === 'stream_trace' ? '流式' : '估算');
111
+ const totalTokens = coercePositiveInteger(usage?.token_estimate?.total_prompt_tokens_estimate);
112
+ const tokenLimit = coercePositiveInteger(usage?.budget_tokens?.total_tokens) || coercePositiveInteger(fallbackTokenLimit);
113
+ const byChars = tokenEstimateDivisor > 0 ? Math.round(textCharCount / tokenEstimateDivisor) : 0;
114
+ const byMessages = messageCount * coercePositiveInteger(baseTokensPerMessage);
115
+ const estimatedTokens = totalTokens > 0 ? totalTokens : Math.max(byChars, byMessages, 0);
116
+ const windowPercent = tokenLimit > 0
117
+ ? Math.max(0, Math.min(100, Math.round((estimatedTokens / tokenLimit) * 100)))
118
+ : 0;
119
+ const modeKey = String(usage?.mode_key || '').trim();
120
+ const modeState = String(usage?.mode_state || '').trim();
121
+ const profile = String(usage?.profile || '').trim();
122
+ const status = buildBudgetStatusLabel({ tokens: estimatedTokens, limit: tokenLimit });
123
+ const layerSummary = buildLayerSummaryLabel(usage?.layers);
124
+ const profileLabel = profile ? ` · ${profile}` : '';
125
+ const modeLabel = modeKey ? ` · ${modeKey}${modeState ? `/${modeState}` : ''}` : '';
126
+ const statusLabel = status ? ` · ${status}` : '';
127
+ const layerLabel = layerSummary ? ` · ${layerSummary}` : '';
128
+ const budgetLabel = tokenLimit > 0
129
+ ? `${estimatedTokens.toLocaleString('zh-CN')} / ${tokenLimit.toLocaleString('zh-CN')}`
130
+ : `${estimatedTokens.toLocaleString('zh-CN')}`;
131
+ return {
132
+ source,
133
+ usage,
134
+ modeKey,
135
+ modeState,
136
+ profile,
137
+ status,
138
+ layerSummary,
139
+ messageCount,
140
+ textCharCount,
141
+ totalTokens,
142
+ estimatedTokens,
143
+ tokenLimit,
144
+ windowPercent,
145
+ text: `${sourceLabel}${profileLabel}${modeLabel}${statusLabel}${layerLabel} · ${budgetLabel}`,
146
+ };
147
+ }
@@ -0,0 +1,74 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildContextUsageViewModel } from './contextUsageViewModel.js';
4
+
5
+ test('buildContextUsageViewModel prefers backend usage over stream trace', () => {
6
+ const result = buildContextUsageViewModel({
7
+ timelineItems: [
8
+ {
9
+ type: 'thinking',
10
+ executionTrace: {
11
+ step_id: 'context_usage',
12
+ detail: 'mode=auto/running; profile=medium; tokens≈444',
13
+ },
14
+ },
15
+ {
16
+ type: 'message',
17
+ role: 'assistant',
18
+ content: 'done',
19
+ contextUsage: {
20
+ profile: 'heavy',
21
+ token_estimate: {
22
+ total_prompt_tokens_estimate: 123,
23
+ },
24
+ layers: [
25
+ { included: true },
26
+ { included: false },
27
+ ],
28
+ },
29
+ },
30
+ ],
31
+ fallbackTokenLimit: 1000,
32
+ });
33
+
34
+ assert.equal(result.source, 'backend');
35
+ assert.equal(result.estimatedTokens, 123);
36
+ assert.equal(result.profile, 'heavy');
37
+ assert.equal(result.layerSummary, 'layers 1/2');
38
+ });
39
+
40
+ test('buildContextUsageViewModel falls back to stream trace then estimate', () => {
41
+ const traceResult = buildContextUsageViewModel({
42
+ timelineItems: [
43
+ {
44
+ type: 'thinking',
45
+ executionTrace: {
46
+ step_id: 'context_usage',
47
+ detail: 'mode=requirement/collecting; profile=light; tokens≈333',
48
+ },
49
+ },
50
+ ],
51
+ fallbackTokenLimit: 1000,
52
+ });
53
+
54
+ assert.equal(traceResult.source, 'stream_trace');
55
+ assert.equal(traceResult.estimatedTokens, 333);
56
+ assert.equal(traceResult.modeKey, 'requirement');
57
+
58
+ const estimateResult = buildContextUsageViewModel({
59
+ timelineItems: [
60
+ {
61
+ type: 'message',
62
+ role: 'user',
63
+ content: '需要一份采购计划。',
64
+ },
65
+ ],
66
+ fallbackTokenLimit: 1000,
67
+ tokenEstimateDivisor: 2,
68
+ baseTokensPerMessage: 10,
69
+ });
70
+
71
+ assert.equal(estimateResult.source, 'estimate');
72
+ assert.ok(estimateResult.estimatedTokens > 0);
73
+ assert.equal(estimateResult.tokenLimit, 1000);
74
+ });
@@ -0,0 +1,41 @@
1
+ import { useMemo } from 'react';
2
+ import { buildTimelineItems } from './buildTimelineItems.js';
3
+ import { buildContextUsageViewModel } from './contextUsageViewModel.js';
4
+
5
+ export function useContextUsageViewModel({
6
+ timelineItems,
7
+ chatSessionState,
8
+ fallbackTokenLimit = 12000,
9
+ tokenEstimateDivisor = 2.4,
10
+ baseTokensPerMessage = 32,
11
+ loading = false,
12
+ }) {
13
+ const resolvedTimelineItems = useMemo(() => {
14
+ if (Array.isArray(timelineItems)) {
15
+ return timelineItems;
16
+ }
17
+ const timeline = buildTimelineItems({
18
+ messages: chatSessionState?.messages,
19
+ uiCards: chatSessionState?.uiCards,
20
+ executionCards: chatSessionState?.executionCards,
21
+ streaming: chatSessionState?.streaming,
22
+ }, { loading });
23
+ return timeline.items;
24
+ }, [
25
+ chatSessionState?.executionCards,
26
+ chatSessionState?.messages,
27
+ chatSessionState?.streaming,
28
+ chatSessionState?.uiCards,
29
+ loading,
30
+ timelineItems,
31
+ ]);
32
+
33
+ return useMemo(() => buildContextUsageViewModel({
34
+ timelineItems: resolvedTimelineItems,
35
+ fallbackTokenLimit,
36
+ tokenEstimateDivisor,
37
+ baseTokensPerMessage,
38
+ }), [resolvedTimelineItems, fallbackTokenLimit, tokenEstimateDivisor, baseTokensPerMessage]);
39
+ }
40
+
41
+ export default useContextUsageViewModel;
@@ -0,0 +1,55 @@
1
+ import { useCallback } from 'react';
2
+
3
+ /**
4
+ * Compose a basic send handler from preflight/execution/recovery boundaries.
5
+ *
6
+ * @param {object} params - Hook params.
7
+ * @param {Function} params.resolveSendPreflight - Preflight resolver.
8
+ * @param {Function} params.executeSendRound - Round executor.
9
+ * @param {Function} params.handleSendFailureRecovery - Failure recovery handler.
10
+ * @param {Array<any>} params.emptyArray - Shared immutable empty array.
11
+ * @returns {{handleSend: Function}}
12
+ */
13
+ export default function useBasicSendHandler({
14
+ resolveSendPreflight,
15
+ executeSendRound,
16
+ handleSendFailureRecovery,
17
+ emptyArray = [],
18
+ }) {
19
+ const handleSend = useCallback(async (options = {}) => {
20
+ let isFirstDraftSend = false;
21
+ let preserveInputOnError = false;
22
+ let content = '';
23
+ let attachedSessionFilesSnapshot = emptyArray;
24
+
25
+ try {
26
+ const sendPreflight = await resolveSendPreflight?.(options);
27
+ if (!sendPreflight || sendPreflight.blocked) {
28
+ return;
29
+ }
30
+
31
+ attachedSessionFilesSnapshot = sendPreflight.attachedSessionFilesSnapshot || emptyArray;
32
+ isFirstDraftSend = sendPreflight.isFirstDraftSend === true;
33
+ preserveInputOnError = sendPreflight.preserveInputOnError === true;
34
+ content = sendPreflight.content || '';
35
+
36
+ await executeSendRound?.({
37
+ sendPreflight,
38
+ isFirstDraftSend,
39
+ });
40
+ return true;
41
+ } catch (error) {
42
+ handleSendFailureRecovery?.({
43
+ preserveInputOnError,
44
+ isFirstDraftSend,
45
+ content,
46
+ attachedSessionFilesSnapshot,
47
+ });
48
+ return error;
49
+ }
50
+ }, [emptyArray, executeSendRound, handleSendFailureRecovery, resolveSendPreflight]);
51
+
52
+ return { handleSend };
53
+ }
54
+
55
+ export { useBasicSendHandler };
@@ -0,0 +1,46 @@
1
+ import { resolvePayloadExtra } from './build-stream-request.js';
2
+
3
+ export function buildRealActionRequestBody({
4
+ action = {},
5
+ options = {},
6
+ scope = {},
7
+ }) {
8
+ const normalizedAction = (
9
+ action
10
+ && typeof action === 'object'
11
+ && !Array.isArray(action)
12
+ ) ? action : {};
13
+ const payloadExtra = resolvePayloadExtra(options);
14
+ const actionPayload = (
15
+ normalizedAction.payload
16
+ && typeof normalizedAction.payload === 'object'
17
+ && !Array.isArray(normalizedAction.payload)
18
+ ) ? normalizedAction.payload : {};
19
+ const resolvedProjectId = String(scope?.projectId || '').trim();
20
+ const resolvedUserId = String(scope?.userId || '').trim();
21
+ const sessionId = String(options.sessionIdOverride || '').trim();
22
+
23
+ return {
24
+ tenant_id: 'default',
25
+ instance_id: 'default',
26
+ domain_pack_version: 'v1',
27
+ session_id: sessionId || null,
28
+ intent: 'default',
29
+ project_id: resolvedProjectId || null,
30
+ user_id: resolvedUserId || null,
31
+ action_key: String(normalizedAction.action_key || '').trim() || null,
32
+ target_mode: String(
33
+ normalizedAction.target_mode
34
+ || actionPayload.target_mode
35
+ || ''
36
+ ).trim() || null,
37
+ action_status: String(normalizedAction.status || '').trim() || null,
38
+ action_reason: String(normalizedAction.reason || '').trim() || null,
39
+ payload: {
40
+ ...actionPayload,
41
+ ...payloadExtra,
42
+ project_id: resolvedProjectId || null,
43
+ user_id: resolvedUserId || null,
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,74 @@
1
+ import { resolveEntities } from './entity-extraction.js';
2
+
3
+ function normalizeRefs(value) {
4
+ if (!Array.isArray(value)) {
5
+ return [];
6
+ }
7
+ return value.filter((item) => item !== undefined && item !== null);
8
+ }
9
+
10
+ export function resolvePayloadExtra(options = {}) {
11
+ return (
12
+ options?.payloadExtra
13
+ && typeof options.payloadExtra === 'object'
14
+ && !Array.isArray(options.payloadExtra)
15
+ ) ? options.payloadExtra : {};
16
+ }
17
+
18
+ export function resolveStreamRefs(options = {}) {
19
+ const payloadExtra = resolvePayloadExtra(options);
20
+
21
+ const contextRefs = Array.isArray(options?.context_refs)
22
+ ? normalizeRefs(options.context_refs)
23
+ : normalizeRefs(payloadExtra.context_refs);
24
+ const knowledgeRefs = Array.isArray(options?.knowledge_refs)
25
+ ? normalizeRefs(options.knowledge_refs)
26
+ : normalizeRefs(payloadExtra.knowledge_refs);
27
+
28
+ return {
29
+ context_refs: contextRefs,
30
+ knowledge_refs: knowledgeRefs,
31
+ };
32
+ }
33
+
34
+ export function buildRealStreamRequestBody({
35
+ content,
36
+ options = {},
37
+ scope = {},
38
+ }) {
39
+ const resolvedProjectId = String(scope?.projectId || '').trim();
40
+ const resolvedUserId = String(scope?.userId || '').trim();
41
+ const sessionId = String(options.sessionIdOverride || '').trim();
42
+ const payloadExtra = resolvePayloadExtra(options);
43
+ const modeKey = String(options?.modeKey || payloadExtra.mode || '').trim();
44
+ const manualModeKey = String(options?.manualModeKey || payloadExtra.manual_mode || '').trim();
45
+ const command = String(options?.command || payloadExtra.command || 'send_message').trim() || 'send_message';
46
+ const refs = resolveStreamRefs(options);
47
+ const entities = resolveEntities({
48
+ text: content,
49
+ entities: Array.isArray(options?.entities) ? options.entities : payloadExtra.entities,
50
+ });
51
+ return {
52
+ tenant_id: 'default',
53
+ instance_id: 'default',
54
+ domain_pack_version: 'v1',
55
+ session_id: sessionId || null,
56
+ command,
57
+ intent: 'default',
58
+ ...(modeKey ? { mode: modeKey } : {}),
59
+ ...(manualModeKey ? { manual_mode: manualModeKey } : {}),
60
+ project_id: resolvedProjectId || null,
61
+ user_id: resolvedUserId || null,
62
+ payload: {
63
+ message: content,
64
+ ...(modeKey ? { mode: modeKey } : {}),
65
+ ...(manualModeKey ? { manual_mode: manualModeKey } : {}),
66
+ project_id: resolvedProjectId || null,
67
+ user_id: resolvedUserId || null,
68
+ ...payloadExtra,
69
+ entities,
70
+ },
71
+ context_refs: refs.context_refs,
72
+ knowledge_refs: refs.knowledge_refs,
73
+ };
74
+ }