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.
- 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 +170 -12
- 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 +62 -455
- package/src/chat-core/session/useSessionListController.js +67 -0
- package/src/chat-core/stream/sse-client.js +1 -244
- package/src/chat-core/stream/sse-event-dispatcher.js +1 -0
- package/src/chat-core/stream/sse-events.js +1 -867
- package/src/chat-core/stream/useSSEStream.js +1 -356
- 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,161 @@
|
|
|
1
|
+
export function ensureFetchImpl(fetchImpl) {
|
|
2
|
+
const resolvedFetch = fetchImpl ?? globalThis.fetch;
|
|
3
|
+
if (typeof resolvedFetch !== 'function') {
|
|
4
|
+
throw new Error('fetch implementation is required');
|
|
5
|
+
}
|
|
6
|
+
return resolvedFetch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function joinUrl(baseUrl = '', path = '') {
|
|
10
|
+
if (!baseUrl) {
|
|
11
|
+
return path || '';
|
|
12
|
+
}
|
|
13
|
+
if (!path) {
|
|
14
|
+
return baseUrl;
|
|
15
|
+
}
|
|
16
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildHeaders({ headers, token }) {
|
|
20
|
+
const resolvedHeaders = new Headers(headers || {});
|
|
21
|
+
if (token) {
|
|
22
|
+
resolvedHeaders.set('Authorization', `Bearer ${token}`);
|
|
23
|
+
}
|
|
24
|
+
return resolvedHeaders;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function parseJsonResponse(response) {
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
if (!text) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createHttpError({ status, statusText, method, url, payload }) {
|
|
40
|
+
const payloadMessage = String(payload?.message || payload?.detail || '').trim();
|
|
41
|
+
const message = payloadMessage || String(statusText || 'request failed');
|
|
42
|
+
const error = new Error(`HTTP ${status}: ${message}`);
|
|
43
|
+
error.status = status;
|
|
44
|
+
error.method = method;
|
|
45
|
+
error.url = url;
|
|
46
|
+
error.payload = payload;
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function requestFormWithUploadProgress({
|
|
51
|
+
url,
|
|
52
|
+
method = 'POST',
|
|
53
|
+
formData,
|
|
54
|
+
headers,
|
|
55
|
+
signal,
|
|
56
|
+
onProgress,
|
|
57
|
+
}) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const xhr = new XMLHttpRequest();
|
|
60
|
+
xhr.open(method, url, true);
|
|
61
|
+
headers.forEach((value, key) => {
|
|
62
|
+
if (String(key).toLowerCase() === 'content-type') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
xhr.setRequestHeader(key, value);
|
|
66
|
+
});
|
|
67
|
+
if (typeof onProgress === 'function') {
|
|
68
|
+
xhr.upload.onprogress = (event) => {
|
|
69
|
+
if (!event.lengthComputable) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const percent = (event.loaded / event.total) * 100;
|
|
73
|
+
onProgress(percent);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
xhr.onload = () => {
|
|
77
|
+
let payload = {};
|
|
78
|
+
const rawText = String(xhr.responseText || '').trim();
|
|
79
|
+
if (rawText) {
|
|
80
|
+
try {
|
|
81
|
+
payload = JSON.parse(rawText);
|
|
82
|
+
} catch {
|
|
83
|
+
payload = {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
87
|
+
resolve(payload);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
reject(createHttpError({
|
|
91
|
+
status: xhr.status,
|
|
92
|
+
statusText: xhr.statusText,
|
|
93
|
+
method,
|
|
94
|
+
url,
|
|
95
|
+
payload,
|
|
96
|
+
}));
|
|
97
|
+
};
|
|
98
|
+
xhr.onerror = () => {
|
|
99
|
+
reject(new Error('Network request failed'));
|
|
100
|
+
};
|
|
101
|
+
xhr.onabort = () => {
|
|
102
|
+
reject(new Error('Request aborted'));
|
|
103
|
+
};
|
|
104
|
+
if (signal) {
|
|
105
|
+
signal.addEventListener('abort', () => xhr.abort(), { once: true });
|
|
106
|
+
}
|
|
107
|
+
xhr.send(formData);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function requestJson(path, { method = 'GET', body, options }) {
|
|
112
|
+
const fetchImpl = ensureFetchImpl(options.fetchImpl);
|
|
113
|
+
const url = joinUrl(options.baseUrl, path);
|
|
114
|
+
const resolvedHeaders = buildHeaders(options);
|
|
115
|
+
resolvedHeaders.set('Content-Type', 'application/json');
|
|
116
|
+
const response = await fetchImpl(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: resolvedHeaders,
|
|
119
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
120
|
+
signal: options.signal,
|
|
121
|
+
});
|
|
122
|
+
const payload = await parseJsonResponse(response);
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw createHttpError({
|
|
125
|
+
status: response.status,
|
|
126
|
+
statusText: response.statusText,
|
|
127
|
+
method,
|
|
128
|
+
url,
|
|
129
|
+
payload,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return payload;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function requestJsonWithRouteFallback({
|
|
136
|
+
buildPath,
|
|
137
|
+
method = 'GET',
|
|
138
|
+
body,
|
|
139
|
+
options,
|
|
140
|
+
routeMode,
|
|
141
|
+
setRouteMode,
|
|
142
|
+
}) {
|
|
143
|
+
const primaryMode = routeMode === 'chat' ? 'chat' : 'root';
|
|
144
|
+
const fallbackMode = primaryMode === 'chat' ? 'root' : 'chat';
|
|
145
|
+
try {
|
|
146
|
+
return await requestJson(buildPath(primaryMode), { method, body, options });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const statusCode = Number(error?.status);
|
|
149
|
+
const errorMessage = String(error?.message || '').trim().toLowerCase();
|
|
150
|
+
const isRouteNotFound = statusCode === 404;
|
|
151
|
+
const isNetworkLikeFailure = !Number.isFinite(statusCode)
|
|
152
|
+
&& (errorMessage.includes('failed to fetch') || errorMessage.includes('network request failed'));
|
|
153
|
+
if (!isRouteNotFound && !isNetworkLikeFailure) {
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
const payloadPath = buildPath(fallbackMode);
|
|
157
|
+
const payload = await requestJson(payloadPath, { method, body, options });
|
|
158
|
+
setRouteMode(fallbackMode);
|
|
159
|
+
return payload;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createSessionAPI } from './session-api.adapter.js';
|
|
2
|
+
import { createMessageAPI } from './message-api.adapter.js';
|
|
3
|
+
import { createSourceAPI } from './source-api.adapter.js';
|
|
4
|
+
|
|
5
|
+
export function createSessionMessageAPI({
|
|
6
|
+
baseUrl = '',
|
|
7
|
+
token = '',
|
|
8
|
+
headers,
|
|
9
|
+
fetchImpl,
|
|
10
|
+
signal,
|
|
11
|
+
} = {}) {
|
|
12
|
+
const requestOptions = {
|
|
13
|
+
baseUrl,
|
|
14
|
+
token: String(token || '').trim(),
|
|
15
|
+
headers,
|
|
16
|
+
fetchImpl,
|
|
17
|
+
signal,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const routeState = {
|
|
21
|
+
sourceRouteMode: 'root',
|
|
22
|
+
fileRouteMode: 'root',
|
|
23
|
+
sessionCollectionRouteMode: 'chat',
|
|
24
|
+
sessionDetailRouteMode: 'chat',
|
|
25
|
+
sessionMessageRouteMode: 'chat',
|
|
26
|
+
messageCollectionRouteMode: 'chat',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
sessionAPI: createSessionAPI(requestOptions, routeState),
|
|
31
|
+
messageAPI: createMessageAPI(requestOptions, routeState),
|
|
32
|
+
sourceAPI: createSourceAPI(requestOptions, routeState),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createSessionMessageAPI } from './session-message-api.js';
|
|
4
|
+
|
|
5
|
+
function buildJsonResponse(payload, status = 200) {
|
|
6
|
+
return new Response(JSON.stringify(payload), {
|
|
7
|
+
status,
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildFetchWithSources(sources) {
|
|
13
|
+
return async () => buildJsonResponse({ sources });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildFetchWithMessages(messages) {
|
|
17
|
+
return async () => buildJsonResponse({ messages });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('normalizeSourceRecord keeps source_id and error_code from server payload', async () => {
|
|
21
|
+
const api = createSessionMessageAPI({
|
|
22
|
+
fetchImpl: buildFetchWithSources([
|
|
23
|
+
{
|
|
24
|
+
id: 'row-1',
|
|
25
|
+
source_id: 'src-1',
|
|
26
|
+
status: 'failed',
|
|
27
|
+
error_code: 'ingest_failed',
|
|
28
|
+
error_message: 'parse failed',
|
|
29
|
+
},
|
|
30
|
+
]),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const response = await api.sourceAPI.listProjectSources('project-1');
|
|
34
|
+
assert.equal(response.sources[0].id, 'row-1');
|
|
35
|
+
assert.equal(response.sources[0].source_id, 'src-1');
|
|
36
|
+
assert.equal(response.sources[0].status, 'failed');
|
|
37
|
+
assert.equal(response.sources[0].error_code, 'ingest_failed');
|
|
38
|
+
assert.equal(response.sources[0].error_message, 'parse failed');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('normalizeSourceRecord falls back source_id to resolved id', async () => {
|
|
42
|
+
const api = createSessionMessageAPI({
|
|
43
|
+
fetchImpl: buildFetchWithSources([
|
|
44
|
+
{
|
|
45
|
+
id: 'row-2',
|
|
46
|
+
},
|
|
47
|
+
]),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const response = await api.sourceAPI.listProjectSources('project-2');
|
|
51
|
+
assert.equal(response.sources[0].id, 'row-2');
|
|
52
|
+
assert.equal(response.sources[0].source_id, 'row-2');
|
|
53
|
+
assert.equal(response.sources[0].error_code, null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('normalizeSourceRecord keeps explicit server status and only defaults when missing', async () => {
|
|
57
|
+
const apiWithExplicitEmptyStatus = createSessionMessageAPI({
|
|
58
|
+
fetchImpl: buildFetchWithSources([
|
|
59
|
+
{
|
|
60
|
+
id: 'row-3',
|
|
61
|
+
status: '',
|
|
62
|
+
},
|
|
63
|
+
]),
|
|
64
|
+
});
|
|
65
|
+
const explicitStatusResponse = await apiWithExplicitEmptyStatus.sourceAPI.listProjectSources('project-3');
|
|
66
|
+
assert.equal(explicitStatusResponse.sources[0].status, '');
|
|
67
|
+
|
|
68
|
+
const apiWithMissingStatus = createSessionMessageAPI({
|
|
69
|
+
fetchImpl: buildFetchWithSources([
|
|
70
|
+
{
|
|
71
|
+
id: 'row-4',
|
|
72
|
+
},
|
|
73
|
+
]),
|
|
74
|
+
});
|
|
75
|
+
const missingStatusResponse = await apiWithMissingStatus.sourceAPI.listProjectSources('project-4');
|
|
76
|
+
assert.equal(missingStatusResponse.sources[0].status, 'ready');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('normalizeSourceRecord keeps explicit null status from server payload', async () => {
|
|
80
|
+
const api = createSessionMessageAPI({
|
|
81
|
+
fetchImpl: buildFetchWithSources([
|
|
82
|
+
{
|
|
83
|
+
id: 'row-5',
|
|
84
|
+
status: null,
|
|
85
|
+
},
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const response = await api.sourceAPI.listProjectSources('project-5');
|
|
90
|
+
assert.equal(response.sources[0].status, null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('listProjectSources appends session_id query when provided', async () => {
|
|
94
|
+
let requestedUrl = '';
|
|
95
|
+
const api = createSessionMessageAPI({
|
|
96
|
+
fetchImpl: async (url) => {
|
|
97
|
+
requestedUrl = String(url || '');
|
|
98
|
+
return buildJsonResponse({ sources: [] });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await api.sourceAPI.listProjectSources('project-6', 'sess-6');
|
|
103
|
+
assert.match(requestedUrl, /\/projects\/project-6\/sources\?session_id=sess-6$/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('normalizeMessageRecord keeps attachments and refs for session replay', async () => {
|
|
107
|
+
const api = createSessionMessageAPI({
|
|
108
|
+
fetchImpl: buildFetchWithMessages([
|
|
109
|
+
{
|
|
110
|
+
message_id: 'm-1',
|
|
111
|
+
session_id: 's-1',
|
|
112
|
+
role: 'user',
|
|
113
|
+
content: '总结附件',
|
|
114
|
+
attachments: [
|
|
115
|
+
{
|
|
116
|
+
source_id: 'src-1',
|
|
117
|
+
filename: '附件一.pdf',
|
|
118
|
+
mime_type: 'application/pdf',
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
context_refs: [{ source_id: 'src-1' }],
|
|
122
|
+
knowledge_refs: ['src-2'],
|
|
123
|
+
},
|
|
124
|
+
]),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const response = await api.messageAPI.list('s-1');
|
|
128
|
+
assert.equal(response.messages[0].attachments.length, 2);
|
|
129
|
+
assert.equal(response.messages[0].attachments[0].filename, '附件一.pdf');
|
|
130
|
+
assert.equal(response.messages[0].attachments[1].source_id, 'src-2');
|
|
131
|
+
assert.deepEqual(response.messages[0].context_refs, [{ source_id: 'src-1' }]);
|
|
132
|
+
assert.deepEqual(response.messages[0].knowledge_refs, [{ source_id: 'src-2' }]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('normalizeMessageRecord keeps sourcing_candidates for canvas sourcing panel projection', async () => {
|
|
136
|
+
const api = createSessionMessageAPI({
|
|
137
|
+
fetchImpl: buildFetchWithMessages([
|
|
138
|
+
{
|
|
139
|
+
message_id: 'm-sourcing-1',
|
|
140
|
+
session_id: 's-sourcing-1',
|
|
141
|
+
role: 'assistant',
|
|
142
|
+
content: '已完成寻源',
|
|
143
|
+
sourcing_candidates: {
|
|
144
|
+
run_id: 'run-1',
|
|
145
|
+
candidates: [
|
|
146
|
+
{ id: 'c-1', title: '供应商A' },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
]),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const response = await api.messageAPI.list('s-sourcing-1');
|
|
154
|
+
assert.equal(response.messages[0].sourcing_candidates.run_id, 'run-1');
|
|
155
|
+
assert.equal(response.messages[0].sourcing_candidates.candidates[0].id, 'c-1');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('uploadSourceFile includes user_id in form data when provided', async () => {
|
|
159
|
+
let capturedUserId = '';
|
|
160
|
+
const api = createSessionMessageAPI({
|
|
161
|
+
fetchImpl: async (_url, init = {}) => {
|
|
162
|
+
const formData = init?.body;
|
|
163
|
+
capturedUserId = String(formData?.get?.('user_id') || '');
|
|
164
|
+
return buildJsonResponse({
|
|
165
|
+
data: {
|
|
166
|
+
source_id: 'src-user-test',
|
|
167
|
+
status: 'uploaded',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await api.sourceAPI.uploadSourceFile({
|
|
174
|
+
project_id: 'project-uid-test',
|
|
175
|
+
user_id: 'user_demo_001',
|
|
176
|
+
file: new File(['hello'], 'probe.txt', { type: 'text/plain' }),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.equal(capturedUserId, 'user_demo_001');
|
|
180
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export function normalizeSessionRecord(record = {}) {
|
|
2
|
+
return {
|
|
3
|
+
sessionId: String(record.session_id || record.sessionId || '').trim(),
|
|
4
|
+
project_id: record.project_id ?? null,
|
|
5
|
+
title: String(record.title || '').trim(),
|
|
6
|
+
preview: String(record.preview || '').trim(),
|
|
7
|
+
title_source: String(record.title_source || '').trim(),
|
|
8
|
+
status: String(record.status || 'active').trim(),
|
|
9
|
+
user_id: record.user_id ?? null,
|
|
10
|
+
function_type: record.function_type ?? null,
|
|
11
|
+
createdAt: record.created_at || record.createdAt || '',
|
|
12
|
+
updatedAt: record.updated_at || record.updatedAt || '',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeMessageRefList(value) {
|
|
17
|
+
if (!Array.isArray(value)) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const refs = [];
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
value.forEach((item) => {
|
|
23
|
+
const sourceId = String(
|
|
24
|
+
typeof item === 'string'
|
|
25
|
+
? item
|
|
26
|
+
: item?.source_id || item?.id || ''
|
|
27
|
+
).trim();
|
|
28
|
+
if (!sourceId || seen.has(sourceId)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
seen.add(sourceId);
|
|
32
|
+
refs.push({ source_id: sourceId });
|
|
33
|
+
});
|
|
34
|
+
return refs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeMessageAttachmentList(value, contextRefs = [], knowledgeRefs = []) {
|
|
38
|
+
const base = Array.isArray(value) ? value : [];
|
|
39
|
+
const attachments = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
base.forEach((item) => {
|
|
42
|
+
const sourceId = String(item?.source_id || item?.id || '').trim();
|
|
43
|
+
const filename = String(item?.filename || item?.name || '').trim();
|
|
44
|
+
const mimeType = String(item?.mime_type || item?.type || '').trim();
|
|
45
|
+
if (!sourceId && !filename) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (sourceId && seen.has(sourceId)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (sourceId) {
|
|
52
|
+
seen.add(sourceId);
|
|
53
|
+
}
|
|
54
|
+
attachments.push({
|
|
55
|
+
source_id: sourceId || '',
|
|
56
|
+
filename: filename || (sourceId ? sourceId : '未命名附件'),
|
|
57
|
+
mime_type: mimeType || '',
|
|
58
|
+
status: String(item?.status || 'ready').trim() || 'ready',
|
|
59
|
+
scope: String(item?.scope || 'session').trim() || 'session',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
[...contextRefs, ...knowledgeRefs].forEach((item) => {
|
|
63
|
+
const sourceId = String(item?.source_id || '').trim();
|
|
64
|
+
if (!sourceId || seen.has(sourceId)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
seen.add(sourceId);
|
|
68
|
+
attachments.push({
|
|
69
|
+
source_id: sourceId,
|
|
70
|
+
filename: sourceId,
|
|
71
|
+
mime_type: '',
|
|
72
|
+
status: 'ready',
|
|
73
|
+
scope: 'session',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
return attachments;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeMessageRecord(record = {}) {
|
|
80
|
+
const contextRefs = normalizeMessageRefList(record.context_refs);
|
|
81
|
+
const knowledgeRefs = normalizeMessageRefList(record.knowledge_refs);
|
|
82
|
+
const agentStatus = (
|
|
83
|
+
record.agent_status
|
|
84
|
+
&& typeof record.agent_status === 'object'
|
|
85
|
+
&& !Array.isArray(record.agent_status)
|
|
86
|
+
) ? { ...record.agent_status } : null;
|
|
87
|
+
const executionTrace = (
|
|
88
|
+
record.execution_trace
|
|
89
|
+
&& typeof record.execution_trace === 'object'
|
|
90
|
+
&& !Array.isArray(record.execution_trace)
|
|
91
|
+
) ? { ...record.execution_trace } : null;
|
|
92
|
+
const knowledgeSearch = (
|
|
93
|
+
record.knowledge_search
|
|
94
|
+
&& typeof record.knowledge_search === 'object'
|
|
95
|
+
&& !Array.isArray(record.knowledge_search)
|
|
96
|
+
) ? { ...record.knowledge_search } : null;
|
|
97
|
+
const knowledgeCitation = (
|
|
98
|
+
record.knowledge_citation
|
|
99
|
+
&& typeof record.knowledge_citation === 'object'
|
|
100
|
+
&& !Array.isArray(record.knowledge_citation)
|
|
101
|
+
) ? { ...record.knowledge_citation } : null;
|
|
102
|
+
const sourcingCandidates = (
|
|
103
|
+
record.sourcing_candidates
|
|
104
|
+
&& typeof record.sourcing_candidates === 'object'
|
|
105
|
+
&& !Array.isArray(record.sourcing_candidates)
|
|
106
|
+
) ? { ...record.sourcing_candidates } : null;
|
|
107
|
+
const contextUsage = (
|
|
108
|
+
record.context_usage
|
|
109
|
+
&& typeof record.context_usage === 'object'
|
|
110
|
+
&& !Array.isArray(record.context_usage)
|
|
111
|
+
) ? { ...record.context_usage } : null;
|
|
112
|
+
return {
|
|
113
|
+
message_id: String(record.message_id || '').trim(),
|
|
114
|
+
session_id: String(record.session_id || '').trim(),
|
|
115
|
+
role: String(record.role || '').trim(),
|
|
116
|
+
content: String(record.content || ''),
|
|
117
|
+
attachments: normalizeMessageAttachmentList(record.attachments, contextRefs, knowledgeRefs),
|
|
118
|
+
context_refs: contextRefs,
|
|
119
|
+
knowledge_refs: knowledgeRefs,
|
|
120
|
+
agent_status: agentStatus,
|
|
121
|
+
thinking_trace: String(record.thinking_trace || ''),
|
|
122
|
+
execution_trace: executionTrace,
|
|
123
|
+
knowledge_search: knowledgeSearch,
|
|
124
|
+
sourcing_candidates: sourcingCandidates,
|
|
125
|
+
knowledge_citation: knowledgeCitation,
|
|
126
|
+
context_usage: contextUsage,
|
|
127
|
+
created_at: record.created_at || '',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function normalizeSourceRecord(record = {}) {
|
|
132
|
+
const resolvedId = String(record.id || record.source_id || record.uid || '').trim();
|
|
133
|
+
const hasStatus = Object.prototype.hasOwnProperty.call(record, 'status');
|
|
134
|
+
const resolvedStatus = hasStatus ? record.status : 'ready';
|
|
135
|
+
return {
|
|
136
|
+
id: resolvedId,
|
|
137
|
+
source_id: String(record.source_id || resolvedId).trim(),
|
|
138
|
+
project_id: record.project_id ?? null,
|
|
139
|
+
message_id: record.message_id ?? null,
|
|
140
|
+
scope: String(record.scope || 'project').trim() || 'project',
|
|
141
|
+
source: String(record.source || 'local_upload').trim() || 'local_upload',
|
|
142
|
+
filename: String(record.filename || record.file_name || record.name || '').trim(),
|
|
143
|
+
mime_type: String(record.mime_type || record.type || '').trim(),
|
|
144
|
+
size: Number.isFinite(record.size) ? record.size : null,
|
|
145
|
+
created_at: record.created_at || '',
|
|
146
|
+
status: resolvedStatus,
|
|
147
|
+
error_code: record.error_code === undefined || record.error_code === null
|
|
148
|
+
? null
|
|
149
|
+
: String(record.error_code).trim(),
|
|
150
|
+
error_message: String(record.error_message || '').trim(),
|
|
151
|
+
persisted_to_project: record.persisted_to_project === true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { normalizeSourceRecord } from './session-message-api.normalizers.js';
|
|
2
|
+
import {
|
|
3
|
+
buildHeaders,
|
|
4
|
+
createHttpError,
|
|
5
|
+
ensureFetchImpl,
|
|
6
|
+
joinUrl,
|
|
7
|
+
parseJsonResponse,
|
|
8
|
+
requestFormWithUploadProgress,
|
|
9
|
+
requestJsonWithRouteFallback,
|
|
10
|
+
} from './session-message-api.http.js';
|
|
11
|
+
|
|
12
|
+
export function createSourceAPI(requestOptions, routeState) {
|
|
13
|
+
return {
|
|
14
|
+
async listProjectSources(projectId, sessionId = '') {
|
|
15
|
+
const resolvedProjectId = String(projectId || '').trim();
|
|
16
|
+
if (!resolvedProjectId) {
|
|
17
|
+
throw new Error('projectId is required');
|
|
18
|
+
}
|
|
19
|
+
const encodedProjectId = encodeURIComponent(resolvedProjectId);
|
|
20
|
+
const resolvedSessionId = String(sessionId || '').trim();
|
|
21
|
+
const query = resolvedSessionId
|
|
22
|
+
? `?session_id=${encodeURIComponent(resolvedSessionId)}`
|
|
23
|
+
: '';
|
|
24
|
+
const response = await requestJsonWithRouteFallback({
|
|
25
|
+
buildPath: (mode) => (mode === 'chat'
|
|
26
|
+
? `/chat/projects/${encodedProjectId}/sources${query}`
|
|
27
|
+
: `/projects/${encodedProjectId}/sources${query}`),
|
|
28
|
+
method: 'GET',
|
|
29
|
+
options: requestOptions,
|
|
30
|
+
routeMode: routeState.sourceRouteMode,
|
|
31
|
+
setRouteMode: (nextMode) => {
|
|
32
|
+
routeState.sourceRouteMode = nextMode;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const source = Array.isArray(response?.sources)
|
|
36
|
+
? response.sources
|
|
37
|
+
: Array.isArray(response?.data?.sources)
|
|
38
|
+
? response.data.sources
|
|
39
|
+
: [];
|
|
40
|
+
return {
|
|
41
|
+
sources: source.map(normalizeSourceRecord),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async uploadSourceFile(payload = {}) {
|
|
46
|
+
const resolvedProjectId = String(payload.project_id || '').trim();
|
|
47
|
+
if (!resolvedProjectId) {
|
|
48
|
+
throw new Error('project_id is required');
|
|
49
|
+
}
|
|
50
|
+
if (!(payload.file instanceof File)) {
|
|
51
|
+
throw new Error('file is required');
|
|
52
|
+
}
|
|
53
|
+
const formData = new FormData();
|
|
54
|
+
formData.append('project_id', resolvedProjectId);
|
|
55
|
+
const resolvedUserId = String(payload.user_id || '').trim();
|
|
56
|
+
if (resolvedUserId) {
|
|
57
|
+
formData.append('user_id', resolvedUserId);
|
|
58
|
+
}
|
|
59
|
+
if (payload.session_id !== undefined && payload.session_id !== null) {
|
|
60
|
+
formData.append('session_id', String(payload.session_id));
|
|
61
|
+
}
|
|
62
|
+
formData.append('file', payload.file, payload.filename || payload.file.name || 'upload.bin');
|
|
63
|
+
|
|
64
|
+
const fetchImpl = ensureFetchImpl(requestOptions.fetchImpl);
|
|
65
|
+
const onProgress = typeof payload.onProgress === 'function' ? payload.onProgress : null;
|
|
66
|
+
const executeUpload = async (mode) => {
|
|
67
|
+
const path = mode === 'chat' ? '/chat/files/upload' : '/files/upload';
|
|
68
|
+
const url = joinUrl(requestOptions.baseUrl, path);
|
|
69
|
+
if (onProgress) {
|
|
70
|
+
return requestFormWithUploadProgress({
|
|
71
|
+
url,
|
|
72
|
+
method: 'POST',
|
|
73
|
+
formData,
|
|
74
|
+
headers: buildHeaders(requestOptions),
|
|
75
|
+
signal: requestOptions.signal,
|
|
76
|
+
onProgress,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const response = await fetchImpl(url, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: buildHeaders(requestOptions),
|
|
82
|
+
body: formData,
|
|
83
|
+
signal: requestOptions.signal,
|
|
84
|
+
});
|
|
85
|
+
const responsePayload = await parseJsonResponse(response);
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw createHttpError({
|
|
88
|
+
status: response.status,
|
|
89
|
+
statusText: response.statusText,
|
|
90
|
+
method: 'POST',
|
|
91
|
+
url,
|
|
92
|
+
payload: responsePayload,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return responsePayload;
|
|
96
|
+
};
|
|
97
|
+
let responsePayload;
|
|
98
|
+
try {
|
|
99
|
+
responsePayload = await executeUpload(routeState.fileRouteMode);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (Number(error?.status) !== 404) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const fallbackMode = routeState.fileRouteMode === 'chat' ? 'root' : 'chat';
|
|
105
|
+
responsePayload = await executeUpload(fallbackMode);
|
|
106
|
+
routeState.fileRouteMode = fallbackMode;
|
|
107
|
+
}
|
|
108
|
+
return normalizeSourceRecord(responsePayload?.data || responsePayload);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async deleteProjectSource(projectId, sourceId) {
|
|
112
|
+
const resolvedProjectId = String(projectId || '').trim();
|
|
113
|
+
const resolvedSourceId = String(sourceId || '').trim();
|
|
114
|
+
if (!resolvedProjectId) {
|
|
115
|
+
throw new Error('projectId is required');
|
|
116
|
+
}
|
|
117
|
+
if (!resolvedSourceId) {
|
|
118
|
+
throw new Error('sourceId is required');
|
|
119
|
+
}
|
|
120
|
+
const encodedProjectId = encodeURIComponent(resolvedProjectId);
|
|
121
|
+
const encodedSourceId = encodeURIComponent(resolvedSourceId);
|
|
122
|
+
return requestJsonWithRouteFallback({
|
|
123
|
+
buildPath: (mode) => (mode === 'chat'
|
|
124
|
+
? `/chat/projects/${encodedProjectId}/sources/${encodedSourceId}`
|
|
125
|
+
: `/projects/${encodedProjectId}/sources/${encodedSourceId}`),
|
|
126
|
+
method: 'DELETE',
|
|
127
|
+
options: requestOptions,
|
|
128
|
+
routeMode: routeState.sourceRouteMode,
|
|
129
|
+
setRouteMode: (nextMode) => {
|
|
130
|
+
routeState.sourceRouteMode = nextMode;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|