flare-chat-core 0.2.0 → 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.
- package/README.md +28 -0
- package/docs/CAPABILITY-INVENTORY.md +42 -0
- package/docs/CHAT-CORE-BOUNDARY.md +47 -0
- package/docs/CORE-APP-REALIGNMENT-WORKLOAD-2026-04-18.md +86 -0
- package/docs/SSOT-CHAT-CORE-BOUNDARY.md +73 -0
- package/docs/SSOT-CHAT-CORE-DATAFLOW.md +97 -0
- package/index.html +12 -0
- package/package.json +24 -2
- package/src/adapters/index.js +6 -0
- package/src/adapters/message-api.adapter.js +59 -0
- package/src/adapters/session-api.adapter.js +133 -0
- package/src/adapters/session-message-api.http.js +161 -0
- package/src/adapters/session-message-api.js +34 -0
- package/src/adapters/session-message-api.normalize-source-record.test.mjs +180 -0
- package/src/adapters/session-message-api.normalizers.js +153 -0
- package/src/adapters/source-api.adapter.js +135 -0
- package/src/adapters/sse-client.js +244 -0
- package/src/adapters/sse-event-dispatcher.js +121 -0
- package/src/app/App.jsx +11 -0
- package/src/app/AppProviders.jsx +12 -0
- package/src/app/ChatWorkspaceScreen.jsx +33 -0
- package/src/app/WorkspaceLayout.jsx +125 -0
- package/src/app/components/AppCanvasPanel.jsx +64 -0
- package/src/app/components/TriggerThresholdPopoverContent.jsx +122 -0
- package/src/app/components/WorkspaceBodySection.jsx +109 -0
- package/src/app/components/WorkspaceMainPane.jsx +113 -0
- package/src/app/components/WorkspaceSessionPane.jsx +48 -0
- package/src/app/components/WorkspaceTopBarSection.jsx +65 -0
- package/src/app/core-chat-entry/ComposerSectionNode.jsx +241 -0
- package/src/app/core-chat-entry/attachmentSendRefs.js +154 -0
- package/src/app/core-chat-entry/attachmentSendRefs.test.mjs +101 -0
- package/src/app/core-chat-entry/composerActionRouter.js +26 -0
- package/src/app/core-chat-entry/constants.js +108 -0
- package/src/app/core-chat-entry/selectors.js +28 -0
- package/src/app/core-chat-entry/useAppActionErrorGuards.js +68 -0
- package/src/app/core-chat-entry/useChatCorePipelines.js +110 -0
- package/src/app/core-chat-entry/useComposerModeSuggestion.js +89 -0
- package/src/app/core-chat-entry/useDevCapabilityStatusNote.js +22 -0
- package/src/app/core-chat-entry/useProjectNameEditing.js +41 -0
- package/src/app/core-chat-entry/useProjectSourceUpload.js +341 -0
- package/src/app/core-chat-entry/useRealApiReadinessGate.js +103 -0
- package/src/app/core-chat-entry/useUnavailableActionError.js +29 -0
- package/src/app/core-chat-entry/useWorkspaceCanvasController.jsx +177 -0
- package/src/app/core-chat-entry/useWorkspaceCanvasProjection.jsx +171 -0
- package/src/app/core-chat-entry/useWorkspaceComposerController.jsx +199 -0
- package/src/app/core-chat-entry/useWorkspaceController.jsx +226 -0
- package/src/app/core-chat-entry/useWorkspacePanels.js +55 -0
- package/src/app/hooks/useComposerAttachmentSync.js +223 -0
- package/src/app/hooks/useComposerChooserHandlers.js +52 -0
- package/src/app/hooks/useSendWithContextRefs.js +140 -0
- package/src/app/hooks/useSendWithContextRefs.test.mjs +29 -0
- package/src/app/hooks/useUserThresholdProfile.js +121 -0
- package/src/app/index.js +1 -0
- package/src/app/selectors/assistantTextSelector.js +73 -0
- package/src/app/selectors/canvasEvidenceSummarySelector.js +28 -0
- package/src/app/selectors/canvasReportTemplateSelector.js +28 -0
- package/src/app/selectors/canvasTabsSelector.js +58 -0
- package/src/app/selectors/evidenceProjectionSelector.js +175 -0
- package/src/app/selectors/evidenceProjectionSelector.test.mjs +107 -0
- package/src/app/selectors/modeSuggestionSelector.js +50 -0
- package/src/chat-core/app/mockRuntime.js +291 -0
- package/src/chat-core/app/useAppStream.js +187 -0
- package/src/chat-core/app/useAppStream.refs.test.mjs +44 -0
- package/src/chat-core/app/useAppStream.request-body.test.mjs +116 -0
- package/src/chat-core/app/useCoreChatApp.js +115 -0
- package/src/chat-core/facade/useBasicConversationFacade.js +280 -0
- package/src/chat-core/index.js +9 -1
- package/src/chat-core/messages/buildTimelineItems.analysis-route.test.mjs +36 -0
- package/src/chat-core/messages/buildTimelineItems.js +172 -11
- package/src/chat-core/messages/buildTimelineItems.knowledge-citation.test.mjs +183 -0
- package/src/chat-core/messages/contextUsageDefaults.js +3 -0
- package/src/chat-core/messages/contextUsageViewModel.js +147 -0
- package/src/chat-core/messages/contextUsageViewModel.test.mjs +74 -0
- package/src/chat-core/messages/useContextUsageViewModel.js +41 -0
- package/src/chat-core/orchestration/useBasicSendHandler.js +55 -0
- package/src/chat-core/pipelines/build-action-request.js +46 -0
- package/src/chat-core/pipelines/build-stream-request.js +74 -0
- package/src/chat-core/pipelines/entity-extraction.js +159 -0
- package/src/chat-core/pipelines/preprocess-message.js +16 -0
- package/src/chat-core/pipelines/stream-persist-utils.js +32 -0
- package/src/chat-core/pipelines/transport/send-mock-stream.js +86 -0
- package/src/chat-core/pipelines/transport/send-real-stream.js +330 -0
- package/src/chat-core/pipelines/transport/send-real-stream.test.mjs +27 -0
- package/src/chat-core/pipelines/transport/send-sourcing-search.js +86 -0
- package/src/chat-core/pipelines/transport/send-sourcing-search.test.mjs +14 -0
- package/src/chat-core/pipelines/transport/sourcing-response-templates.js +55 -0
- package/src/chat-core/pipelines/transport/sourcing-search-api.js +155 -0
- package/src/chat-core/runtime/runtimeMode.js +69 -0
- package/src/chat-core/session/chatSessionActionTypes.js +24 -0
- package/src/chat-core/session/chatSessionReducer.js +352 -0
- package/src/chat-core/session/chatSessionReducer.streaming-done.test.mjs +39 -0
- package/src/chat-core/session/index.js +2 -0
- package/src/chat-core/session/sessionActionsMessages.js +44 -0
- package/src/chat-core/session/sessionActionsSessionCrud.js +131 -0
- package/src/chat-core/session/sessionActionsStreaming.js +80 -0
- package/src/chat-core/session/sessionActionsUiState.js +51 -0
- package/src/chat-core/session/useChatSessionReducer.js +67 -390
- package/src/chat-core/session/useSessionListController.js +67 -0
- package/src/chat-core/stream/sse-client.js +1 -142
- package/src/chat-core/stream/sse-event-dispatcher.js +1 -0
- package/src/chat-core/stream/sse-events.js +1 -598
- package/src/chat-core/stream/useSSEStream.js +1 -273
- package/src/chat-core/stream/useStreamSendController.js +46 -0
- package/src/contracts/context-ssot.js +47 -0
- package/src/contracts/index.js +1 -0
- package/src/contracts/sse-events/base-parsers.js +79 -0
- package/src/contracts/sse-events/domain-parsers.js +3 -0
- package/src/contracts/sse-events/internal-normalizers.js +143 -0
- package/src/contracts/sse-events/parsers-intake.js +235 -0
- package/src/contracts/sse-events/parsers-runtime.js +37 -0
- package/src/contracts/sse-events/parsers-sourcing.js +179 -0
- package/src/contracts/sse-events/patch-event-parser.js +121 -0
- package/src/contracts/sse-events/runtime-parsers.js +79 -0
- package/src/contracts/sse-events.js +4 -0
- package/src/index.js +5 -0
- package/src/main.jsx +28 -0
- package/src/orchestration/index.js +6 -0
- package/src/orchestration/useSSEStream.js +221 -0
- package/src/state/index.js +4 -0
- 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,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
|
+
}
|