flare-chat-core 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +190 -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 +156 -0
  26. package/src/app/components/WorkspaceMainPane.jsx +121 -0
  27. package/src/app/components/WorkspaceSessionPane.jsx +70 -0
  28. package/src/app/components/WorkspaceTopBarSection.jsx +71 -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 +139 -13
  70. package/src/chat-core/messages/buildTimelineItems.knowledge-citation.test.mjs +182 -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,68 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ function normalizeError(error, fallbackMessage) {
4
+ if (error instanceof Error) {
5
+ return error;
6
+ }
7
+ return new Error(String(error || fallbackMessage));
8
+ }
9
+
10
+ export function useAppActionErrorGuards({
11
+ blocked = false,
12
+ blockedError,
13
+ sessionListError = null,
14
+ composerError = null,
15
+ onCreateProject,
16
+ onCreateSession,
17
+ onSend,
18
+ } = {}) {
19
+ const [actionError, setActionError] = useState(null);
20
+
21
+ const handleCreateProject = useCallback(async () => {
22
+ if (blocked) {
23
+ setActionError(blockedError);
24
+ return;
25
+ }
26
+ try {
27
+ setActionError(null);
28
+ await onCreateProject?.();
29
+ } catch (nextError) {
30
+ setActionError(normalizeError(nextError, '创建项目失败'));
31
+ }
32
+ }, [blocked, blockedError, onCreateProject]);
33
+
34
+ const handleCreateSession = useCallback(async () => {
35
+ if (blocked) {
36
+ setActionError(blockedError);
37
+ return;
38
+ }
39
+ try {
40
+ setActionError(null);
41
+ await onCreateSession?.();
42
+ } catch (nextError) {
43
+ setActionError(normalizeError(nextError, '创建会话失败'));
44
+ }
45
+ }, [blocked, blockedError, onCreateSession]);
46
+
47
+ const handleSend = useCallback(async (options = {}) => {
48
+ if (blocked) {
49
+ setActionError(blockedError);
50
+ return;
51
+ }
52
+ setActionError(null);
53
+ const sendResult = await onSend?.(options);
54
+ if (sendResult instanceof Error) {
55
+ setActionError(sendResult);
56
+ }
57
+ return sendResult;
58
+ }, [blocked, blockedError, onSend]);
59
+
60
+ return useMemo(() => ({
61
+ actionError,
62
+ handleCreateProject,
63
+ handleCreateSession,
64
+ handleSend,
65
+ sessionPaneError: actionError || sessionListError || null,
66
+ streamError: actionError || composerError || null,
67
+ }), [actionError, composerError, handleCreateProject, handleCreateSession, handleSend, sessionListError]);
68
+ }
@@ -0,0 +1,110 @@
1
+ import { useMemo } from 'react';
2
+ import { useCoreChatApp } from '../../chat-core/app/useCoreChatApp.js';
3
+ import { useAppStream } from '../../chat-core/app/useAppStream.js';
4
+ import { createMockRuntime } from '../../chat-core/app/mockRuntime.js';
5
+ import { createSessionMessageAPI } from '../../adapters/session-message-api.js';
6
+ import { useRealApiReadinessGate } from './useRealApiReadinessGate.js';
7
+
8
+ export function useChatCorePipelines({
9
+ functionType,
10
+ defaultSessionTitle,
11
+ projectId,
12
+ userId,
13
+ backendMode,
14
+ kernelBaseUrl,
15
+ apiBaseUrl,
16
+ apiToken,
17
+ sessionAPI,
18
+ messageAPI,
19
+ }) {
20
+ const normalizedApiBaseUrl = String(apiBaseUrl || '').trim();
21
+ const runtime = useMemo(() => createMockRuntime({
22
+ defaultFunctionType: functionType,
23
+ defaultSessionTitle,
24
+ }), [defaultSessionTitle, functionType]);
25
+
26
+ const scope = useMemo(() => ({
27
+ projectId,
28
+ userId,
29
+ }), [projectId, userId]);
30
+
31
+ const listParams = useMemo(() => ({
32
+ status: 'active',
33
+ page: 1,
34
+ page_size: 20,
35
+ function_type: functionType,
36
+ project_id: projectId,
37
+ }), [functionType, projectId]);
38
+
39
+ const shouldUseRealAPI = backendMode === 'real' && !sessionAPI && !messageAPI;
40
+ const apiReadinessGate = useRealApiReadinessGate({
41
+ enabled: shouldUseRealAPI,
42
+ apiBaseUrl: normalizedApiBaseUrl,
43
+ apiToken,
44
+ });
45
+ const blockedApiError = useMemo(() => (
46
+ apiReadinessGate.error || new Error('API 不可用,请检查配置与服务状态')
47
+ ), [apiReadinessGate.error]);
48
+ const blockedSessionAPI = useMemo(() => ({
49
+ list: async () => ({ sessions: [] }),
50
+ get: async () => {
51
+ throw blockedApiError;
52
+ },
53
+ create: async () => {
54
+ throw blockedApiError;
55
+ },
56
+ update: async () => {
57
+ throw blockedApiError;
58
+ },
59
+ }), [blockedApiError]);
60
+ const blockedMessageAPI = useMemo(() => ({
61
+ list: async () => {
62
+ throw blockedApiError;
63
+ },
64
+ }), [blockedApiError]);
65
+ const realApi = useMemo(() => {
66
+ if (!shouldUseRealAPI || apiReadinessGate.blocked) {
67
+ return null;
68
+ }
69
+ return createSessionMessageAPI({
70
+ baseUrl: normalizedApiBaseUrl,
71
+ token: apiToken,
72
+ });
73
+ }, [apiReadinessGate.blocked, apiToken, normalizedApiBaseUrl, shouldUseRealAPI]);
74
+ const resolvedSessionAPI = sessionAPI
75
+ || realApi?.sessionAPI
76
+ || (shouldUseRealAPI ? blockedSessionAPI : runtime.sessionAPI);
77
+ const resolvedMessageAPI = messageAPI
78
+ || realApi?.messageAPI
79
+ || (shouldUseRealAPI ? blockedMessageAPI : runtime.messageAPI);
80
+ const useRuntimePersistence = !shouldUseRealAPI && !sessionAPI && !messageAPI;
81
+
82
+ const stream = useAppStream({
83
+ runtime,
84
+ backendMode,
85
+ apiBaseUrl: normalizedApiBaseUrl,
86
+ kernelBaseUrl,
87
+ scope,
88
+ persistExchange: useRuntimePersistence ? runtime.appendExchange : null,
89
+ });
90
+
91
+ const { viewModel } = useCoreChatApp({
92
+ sessionAPI: resolvedSessionAPI,
93
+ messageAPI: resolvedMessageAPI,
94
+ stream,
95
+ functionType,
96
+ defaultSessionTitle,
97
+ listParams,
98
+ createSessionPayload: {
99
+ project_id: projectId,
100
+ user_id: userId,
101
+ },
102
+ });
103
+
104
+ return {
105
+ apiReadinessGate,
106
+ blockedApiError,
107
+ realApi,
108
+ viewModel,
109
+ };
110
+ }
@@ -0,0 +1,89 @@
1
+ import { useMemo } from 'react';
2
+
3
+ function normalizeText(value) {
4
+ return String(value || '').trim().toLowerCase();
5
+ }
6
+
7
+ function hasAnyKeyword(content, keywords = []) {
8
+ return keywords.some((keyword) => content.includes(String(keyword || '').trim().toLowerCase()));
9
+ }
10
+
11
+ function buildSuggestionFromInput({
12
+ inputValue,
13
+ activeModeKey,
14
+ tipRules = [],
15
+ contextFlags = {},
16
+ }) {
17
+ const resolvedModeKey = String(activeModeKey || '').trim();
18
+ if (resolvedModeKey !== 'auto') {
19
+ return null;
20
+ }
21
+ const normalizedInput = normalizeText(inputValue);
22
+ const normalizedRules = Array.isArray(tipRules) ? tipRules : [];
23
+ if (!normalizedInput && normalizedRules.length === 0) {
24
+ return null;
25
+ }
26
+ const matchedRule = normalizedRules.find((rule) => {
27
+ const keywords = Array.isArray(rule?.keywords) ? rule.keywords : [];
28
+ const hasKeywordMatch = normalizedInput && hasAnyKeyword(normalizedInput, keywords);
29
+ const contextKey = String(rule?.whenContextKey || '').trim();
30
+ const hasContextMatch = contextKey ? contextFlags?.[contextKey] === true : false;
31
+ return hasKeywordMatch || hasContextMatch;
32
+ });
33
+ if (!matchedRule) {
34
+ return null;
35
+ }
36
+ return {
37
+ source: String(matchedRule.source || 'local_input_tip'),
38
+ modeKey: String(matchedRule.modeKey || '').trim(),
39
+ label: String(matchedRule.label || '').trim(),
40
+ reason: String(matchedRule.reason || '').trim(),
41
+ actionText: String(matchedRule.actionText || '').trim(),
42
+ statusOnly: matchedRule.statusOnly === true,
43
+ };
44
+ }
45
+
46
+ export default function useComposerModeSuggestion({
47
+ inputValue,
48
+ activeModeKey,
49
+ modeSuggestion,
50
+ modeSuggestionAction,
51
+ tipRules = [],
52
+ contextFlags = {},
53
+ }) {
54
+ return useMemo(() => {
55
+ if (modeSuggestionAction && typeof modeSuggestionAction === 'object') {
56
+ return {
57
+ modeSuggestion: modeSuggestion || null,
58
+ modeSuggestionAction,
59
+ fromFallback: false,
60
+ };
61
+ }
62
+ const fallbackSuggestion = buildSuggestionFromInput({
63
+ inputValue,
64
+ activeModeKey,
65
+ tipRules,
66
+ contextFlags,
67
+ });
68
+ if (!fallbackSuggestion) {
69
+ return {
70
+ modeSuggestion: modeSuggestion || null,
71
+ modeSuggestionAction: null,
72
+ fromFallback: false,
73
+ };
74
+ }
75
+ return {
76
+ modeSuggestion: fallbackSuggestion,
77
+ modeSuggestionAction: {
78
+ source: 'mode_suggestion',
79
+ text: fallbackSuggestion.actionText || `启用${fallbackSuggestion.label || fallbackSuggestion.modeKey || ''}`,
80
+ action: {
81
+ action_type: 'switch_mode',
82
+ target_mode: fallbackSuggestion.modeKey,
83
+ label: fallbackSuggestion.actionText || '',
84
+ },
85
+ },
86
+ fromFallback: true,
87
+ };
88
+ }, [activeModeKey, contextFlags, inputValue, modeSuggestion, modeSuggestionAction, tipRules]);
89
+ }
@@ -0,0 +1,22 @@
1
+ import { useMemo } from 'react';
2
+
3
+ export function useDevCapabilityStatusNote({
4
+ isDev = false,
5
+ mode = '',
6
+ labels = {},
7
+ } = {}) {
8
+ return useMemo(() => {
9
+ if (!isDev) {
10
+ return '';
11
+ }
12
+ const normalizedMode = String(mode || '').trim().toLowerCase();
13
+ if (normalizedMode === 'persistent') {
14
+ return String(labels.persistent || '开发态:资料能力已接入持久化链路');
15
+ }
16
+ if (normalizedMode === 'local_fallback') {
17
+ return String(labels.localFallback || '开发态:资料能力当前为本地回退模式');
18
+ }
19
+ return String(labels.unknown || '开发态:资料能力状态未知');
20
+ }, [isDev, labels.localFallback, labels.persistent, labels.unknown, mode]);
21
+ }
22
+
@@ -0,0 +1,41 @@
1
+ import { useState } from 'react';
2
+
3
+ export function useProjectNameEditing({ defaultProjectName } = {}) {
4
+ const initialName = String(defaultProjectName || '').trim() || '本地演示项目';
5
+ const [projectNameEditing, setProjectNameEditing] = useState(false);
6
+ const [projectNameDraft, setProjectNameDraft] = useState(initialName);
7
+ const [projectDisplayName, setProjectDisplayName] = useState(initialName);
8
+
9
+ const handleProjectNameStartEdit = (projectSlot) => {
10
+ if (!projectSlot?.key) {
11
+ return;
12
+ }
13
+ setProjectNameDraft(String(projectDisplayName || '').trim());
14
+ setProjectNameEditing(true);
15
+ };
16
+
17
+ const handleProjectNameCancel = () => {
18
+ setProjectNameEditing(false);
19
+ setProjectNameDraft('');
20
+ };
21
+
22
+ const handleProjectNameSave = () => {
23
+ const nextName = String(projectNameDraft || '').trim();
24
+ if (nextName) {
25
+ setProjectDisplayName(nextName);
26
+ }
27
+ handleProjectNameCancel();
28
+ };
29
+
30
+ return {
31
+ projectNameEditing,
32
+ projectNameDraft,
33
+ setProjectNameDraft,
34
+ projectDisplayName,
35
+ handleProjectNameStartEdit,
36
+ handleProjectNameCancel,
37
+ handleProjectNameSave,
38
+ };
39
+ }
40
+
41
+ export default useProjectNameEditing;
@@ -0,0 +1,341 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ const projectSourceListSingleflight = new Map();
4
+ const SOURCE_STATUS_TERMINAL = new Set(['ready', 'failed', 'error']);
5
+
6
+ function normalizeError(error, fallbackMessage) {
7
+ if (error instanceof Error) {
8
+ return error;
9
+ }
10
+ return new Error(String(error || fallbackMessage));
11
+ }
12
+
13
+ function buildSourceItem(file, index, scopedProjectId) {
14
+ return {
15
+ id: `project-source-${Date.now()}-${index}`,
16
+ project_id: scopedProjectId,
17
+ message_id: null,
18
+ scope: 'project',
19
+ source: 'local_upload',
20
+ filename: String(file?.name || '').trim() || `资料 ${index + 1}`,
21
+ mime_type: String(file?.type || '').trim(),
22
+ size: Number.isFinite(file?.size) ? file.size : null,
23
+ created_at: new Date().toISOString(),
24
+ status: 'ready',
25
+ error_message: '',
26
+ persisted_to_project: false,
27
+ };
28
+ }
29
+
30
+ function buildSourceFileSignature(item = {}) {
31
+ return `${item.filename || ''}::${item.size || ''}::${item.mime_type || ''}`;
32
+ }
33
+
34
+ function parseSourceList(response) {
35
+ if (Array.isArray(response?.sources)) {
36
+ return response.sources;
37
+ }
38
+ if (Array.isArray(response?.data?.sources)) {
39
+ return response.data.sources;
40
+ }
41
+ return [];
42
+ }
43
+
44
+ function normalizeSourceStatus(status) {
45
+ return String(status || '').trim().toLowerCase();
46
+ }
47
+
48
+ function hasPendingSourceProcessing(items = []) {
49
+ if (!Array.isArray(items) || items.length === 0) {
50
+ return false;
51
+ }
52
+ return items.some((item) => !SOURCE_STATUS_TERMINAL.has(normalizeSourceStatus(item?.status)));
53
+ }
54
+
55
+ function createUploadProgressItem(file, index) {
56
+ return {
57
+ id: `upload-progress-${Date.now()}-${index}`,
58
+ filename: String(file?.name || '').trim() || `资料 ${index + 1}`,
59
+ progress: 0,
60
+ stage: 'reading',
61
+ error_message: '',
62
+ };
63
+ }
64
+
65
+ function readFileWithProgress(file, onProgress) {
66
+ return new Promise((resolve, reject) => {
67
+ const reader = new FileReader();
68
+ reader.onprogress = (event) => {
69
+ if (!event.lengthComputable) {
70
+ return;
71
+ }
72
+ const percent = (event.loaded / event.total) * 80;
73
+ onProgress(percent);
74
+ };
75
+ reader.onload = () => {
76
+ onProgress(80);
77
+ resolve();
78
+ };
79
+ reader.onerror = () => {
80
+ reject(reader.error || new Error('读取文件失败'));
81
+ };
82
+ reader.readAsArrayBuffer(file);
83
+ });
84
+ }
85
+
86
+ export function useProjectSourceUpload({
87
+ projectId = '',
88
+ userId = '',
89
+ sourceAPI = null,
90
+ shouldLoadSources = true,
91
+ } = {}) {
92
+ const fileInputRef = useRef(null);
93
+ const [sourceItems, setSourceItems] = useState([]);
94
+ const [sourceActionError, setSourceActionError] = useState(null);
95
+ const [sourceUploadLoading, setSourceUploadLoading] = useState(false);
96
+ const [sourceUploadProgressItems, setSourceUploadProgressItems] = useState([]);
97
+ const [sourceSyncLoading, setSourceSyncLoading] = useState(false);
98
+ const [sourceRemovingId, setSourceRemovingId] = useState('');
99
+ const [sourceEndpointUnavailable, setSourceEndpointUnavailable] = useState(false);
100
+
101
+ const canUsePersistentSourceAPI = (
102
+ typeof sourceAPI?.listProjectSources === 'function'
103
+ && typeof sourceAPI?.uploadSourceFile === 'function'
104
+ && typeof sourceAPI?.deleteProjectSource === 'function'
105
+ && !sourceEndpointUnavailable
106
+ );
107
+
108
+ const loadProjectSources = useCallback(async ({ silent = false } = {}) => {
109
+ const scopedProjectId = String(projectId || '').trim();
110
+ if (!canUsePersistentSourceAPI || !scopedProjectId) {
111
+ return;
112
+ }
113
+ if (!silent) {
114
+ setSourceSyncLoading(true);
115
+ setSourceActionError(null);
116
+ }
117
+ try {
118
+ const requestKey = scopedProjectId;
119
+ let pendingRequest = projectSourceListSingleflight.get(requestKey);
120
+ if (!pendingRequest) {
121
+ pendingRequest = sourceAPI.listProjectSources(scopedProjectId)
122
+ .finally(() => {
123
+ projectSourceListSingleflight.delete(requestKey);
124
+ });
125
+ projectSourceListSingleflight.set(requestKey, pendingRequest);
126
+ }
127
+ const response = await pendingRequest;
128
+ setSourceItems(parseSourceList(response));
129
+ setSourceEndpointUnavailable(false);
130
+ } catch (error) {
131
+ if (Number(error?.status) === 404) {
132
+ setSourceEndpointUnavailable(true);
133
+ if (!silent) {
134
+ setSourceActionError(null);
135
+ }
136
+ return;
137
+ }
138
+ if (!silent) {
139
+ setSourceActionError(normalizeError(error, '加载资料失败'));
140
+ }
141
+ } finally {
142
+ if (!silent) {
143
+ setSourceSyncLoading(false);
144
+ }
145
+ }
146
+ }, [canUsePersistentSourceAPI, projectId, sourceAPI]);
147
+
148
+ useEffect(() => {
149
+ if (!shouldLoadSources) {
150
+ return;
151
+ }
152
+ loadProjectSources();
153
+ }, [loadProjectSources, shouldLoadSources]);
154
+
155
+ useEffect(() => {
156
+ if (!shouldLoadSources) {
157
+ return undefined;
158
+ }
159
+ if (!canUsePersistentSourceAPI) {
160
+ return undefined;
161
+ }
162
+ if (!hasPendingSourceProcessing(sourceItems)) {
163
+ return undefined;
164
+ }
165
+ const timer = setInterval(() => {
166
+ void loadProjectSources({ silent: true });
167
+ }, 1500);
168
+ return () => {
169
+ clearInterval(timer);
170
+ };
171
+ }, [canUsePersistentSourceAPI, loadProjectSources, shouldLoadSources, sourceItems]);
172
+
173
+ const appendLocalSourceFiles = useCallback((nativeFiles = []) => {
174
+ setSourceItems((prev) => {
175
+ const existingSignatures = new Set(prev.map((item) => buildSourceFileSignature(item)));
176
+ const nextItems = nativeFiles
177
+ .map((file, index) => buildSourceItem(file, index, projectId))
178
+ .filter((item) => {
179
+ const signature = buildSourceFileSignature(item);
180
+ if (existingSignatures.has(signature)) {
181
+ return false;
182
+ }
183
+ existingSignatures.add(signature);
184
+ return true;
185
+ });
186
+ return [...nextItems, ...prev];
187
+ });
188
+ }, [projectId]);
189
+
190
+ const uploadSourceFiles = useCallback(async (nativeFiles = []) => {
191
+ const scopedProjectId = String(projectId || '').trim();
192
+ if (nativeFiles.length === 0) {
193
+ return;
194
+ }
195
+ setSourceUploadLoading(true);
196
+ setSourceActionError(null);
197
+ const progressItems = nativeFiles.map((file, index) => createUploadProgressItem(file, index));
198
+ setSourceUploadProgressItems((prev) => [...progressItems, ...prev]);
199
+ const updateProgressItem = (itemId, patch = {}) => {
200
+ setSourceUploadProgressItems((prev) => prev.map((item) => (item.id === itemId ? { ...item, ...patch } : item)));
201
+ };
202
+ try {
203
+ if (canUsePersistentSourceAPI && scopedProjectId) {
204
+ for (let index = 0; index < nativeFiles.length; index += 1) {
205
+ const file = nativeFiles[index];
206
+ const progressItem = progressItems[index];
207
+ await readFileWithProgress(file, (percent) => {
208
+ updateProgressItem(progressItem.id, {
209
+ stage: 'reading',
210
+ progress: percent,
211
+ });
212
+ });
213
+ updateProgressItem(progressItem.id, {
214
+ stage: 'uploading',
215
+ progress: 80,
216
+ });
217
+ await sourceAPI.uploadSourceFile({
218
+ project_id: scopedProjectId,
219
+ user_id: String(userId || '').trim() || null,
220
+ file,
221
+ filename: file.name,
222
+ onProgress: (percent) => {
223
+ const safePercent = Number.isFinite(percent) ? percent : 0;
224
+ const mappedPercent = 80 + (Math.max(0, Math.min(100, safePercent)) * 0.2);
225
+ updateProgressItem(progressItem.id, {
226
+ stage: 'uploading',
227
+ progress: mappedPercent,
228
+ });
229
+ },
230
+ });
231
+ updateProgressItem(progressItem.id, {
232
+ stage: 'done',
233
+ progress: 100,
234
+ });
235
+ }
236
+ await loadProjectSources();
237
+ return;
238
+ }
239
+ for (let index = 0; index < nativeFiles.length; index += 1) {
240
+ const file = nativeFiles[index];
241
+ const progressItem = progressItems[index];
242
+ await readFileWithProgress(file, (percent) => {
243
+ updateProgressItem(progressItem.id, {
244
+ stage: 'reading',
245
+ progress: percent,
246
+ });
247
+ });
248
+ updateProgressItem(progressItem.id, {
249
+ stage: 'uploading',
250
+ progress: 95,
251
+ });
252
+ appendLocalSourceFiles([file]);
253
+ updateProgressItem(progressItem.id, {
254
+ stage: 'done',
255
+ progress: 100,
256
+ });
257
+ }
258
+ } catch (error) {
259
+ const errorMessage = String(error?.message || '添加资料失败');
260
+ setSourceUploadProgressItems((prev) => prev.map((item) => (item.stage === 'done'
261
+ ? item
262
+ : {
263
+ ...item,
264
+ stage: 'error',
265
+ error_message: errorMessage,
266
+ })));
267
+ setSourceActionError(normalizeError(error, '添加资料失败'));
268
+ } finally {
269
+ setSourceUploadLoading(false);
270
+ }
271
+ }, [appendLocalSourceFiles, canUsePersistentSourceAPI, loadProjectSources, projectId, sourceAPI, userId]);
272
+
273
+ const handleOpenSourcePicker = useCallback((nativeFiles = []) => {
274
+ if (sourceUploadLoading) {
275
+ return;
276
+ }
277
+ if (Array.isArray(nativeFiles) && nativeFiles.length > 0) {
278
+ void uploadSourceFiles(nativeFiles);
279
+ return;
280
+ }
281
+ setSourceActionError(null);
282
+ fileInputRef.current?.click();
283
+ }, [sourceUploadLoading, uploadSourceFiles]);
284
+
285
+ const handleSourceFileChange = useCallback((event) => {
286
+ const nativeFiles = Array.from(event?.target?.files || []);
287
+ event.target.value = '';
288
+ void uploadSourceFiles(nativeFiles);
289
+ }, [uploadSourceFiles]);
290
+
291
+ const handleRemoveSource = useCallback(async (sourceId) => {
292
+ const scopedProjectId = String(projectId || '').trim();
293
+ const normalizedSourceId = String(sourceId || '').trim();
294
+ if (!normalizedSourceId) {
295
+ return;
296
+ }
297
+ setSourceActionError(null);
298
+ setSourceRemovingId(normalizedSourceId);
299
+ try {
300
+ if (canUsePersistentSourceAPI && scopedProjectId) {
301
+ await sourceAPI.deleteProjectSource(scopedProjectId, normalizedSourceId);
302
+ await loadProjectSources();
303
+ } else {
304
+ setSourceItems((prev) => prev.filter((item) => {
305
+ const currentSourceId = String(item?.source_id || item?.id || '').trim();
306
+ return currentSourceId !== normalizedSourceId;
307
+ }));
308
+ }
309
+ } catch (error) {
310
+ setSourceActionError(normalizeError(error, '删除资料失败'));
311
+ } finally {
312
+ setSourceRemovingId('');
313
+ }
314
+ }, [canUsePersistentSourceAPI, loadProjectSources, projectId, sourceAPI]);
315
+
316
+ const handleRetrySource = useCallback(() => {
317
+ if (canUsePersistentSourceAPI) {
318
+ loadProjectSources();
319
+ return;
320
+ }
321
+ setSourceActionError(null);
322
+ }, [canUsePersistentSourceAPI, loadProjectSources]);
323
+
324
+ const handleViewSourceDetail = useCallback(() => {}, []);
325
+
326
+ return {
327
+ fileInputRef,
328
+ sourceCapabilityMode: canUsePersistentSourceAPI ? 'persistent' : 'local_fallback',
329
+ sourceActionError,
330
+ sourceItems,
331
+ sourceRemovingId,
332
+ sourceSyncLoading,
333
+ sourceUploadLoading,
334
+ sourceUploadProgressItems,
335
+ handleOpenSourcePicker,
336
+ handleSourceFileChange,
337
+ handleRemoveSource,
338
+ handleRetrySource,
339
+ handleViewSourceDetail,
340
+ };
341
+ }