App.tsx hardcoded fallbacks (active session "sess-001", projectId 1) sent requests for nonexistent resources on fresh logins against real dev data, producing 7+ console 404/403 hits. Fallbacks now resolve to empty/0; the existing chatBlockedNoProject empty state and session-reconciliation effect carry the UI, and demo-seeds-enabled builds keep the NEUROPULSE fallback. Root cause, same-class scan, and before/after browser evidence (9 phantom requests -> 0, author+member accounts) recorded in the task doc; ISSUES row ISSUE-20260927-demo-fallback registered.
1759 lines
67 KiB
TypeScript
1759 lines
67 KiB
TypeScript
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||
import { WorkbenchView } from './components/layout/WorkbenchView';
|
||
import { DocumentPane } from './components/doc/DocumentPane';
|
||
import { ChatColumn } from './components/chat/ChatColumn';
|
||
import { fetchFreeformDoc } from './api/freeformDoc';
|
||
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
|
||
import { fetchProjectLineage, getProject } from './api/projectWorkflow';
|
||
import { listProjectMembers } from './api/projectMembers';
|
||
import {
|
||
type CreateProjectSubmitPhase,
|
||
} from './lib/createProjectSubmitGuard';
|
||
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
|
||
import { resolveCurrentDocRole } from './lib/currentDocRole';
|
||
import { expandRightDocRail } from './lib/expandRightDocRail';
|
||
import {
|
||
resolveChatComposerStreaming,
|
||
} from './lib/chatComposerStreaming';
|
||
import {
|
||
decideModificationProposal,
|
||
} from './api/docgov';
|
||
import {
|
||
buildControlledLoadingSkeleton,
|
||
controlledHydrateFingerprint,
|
||
hydrateControlledDocumentContent,
|
||
shouldLoadControlledContent,
|
||
} from './lib/controlledDocHydrate';
|
||
import {
|
||
dropRecordKeys,
|
||
type ControlledInvalidationPlan,
|
||
} from './lib/controlledContentInvalidation';
|
||
import {
|
||
createEditorHydrationGate,
|
||
} from './lib/editorHydrationGate';
|
||
import {
|
||
decideActiveDocSwitch,
|
||
type BlockEditorSession,
|
||
} from './lib/blockSavePolicy';
|
||
import { DocumentLineage } from './types/projectWorkflow';
|
||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||
import { OnDemand } from './components/lazy/OnDemand';
|
||
import { fetchProjectSettings, reassignDocTaskDuties } from './api/projectSettings';
|
||
import { LoginView } from './components/auth/LoginView';
|
||
import { MaintenanceBanner } from './components/maintenance/MaintenanceBanner';
|
||
import { authClient } from './api/auth';
|
||
import { LoginResult, SystemAuthConfig } from './types/auth';
|
||
import { markdownToBlockAST } from './engine/markdownToBlock';
|
||
import { blockASTToMarkdown } from './engine/blockToMarkdown';
|
||
import { DocumentAST, DocumentBlock } from './types/block';
|
||
import {
|
||
ProjectSession,
|
||
ChatMessage,
|
||
DeliverableItem,
|
||
DeliverableCategory,
|
||
ProjectInfo,
|
||
ProjectMember,
|
||
} from './types/workbench';
|
||
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
|
||
import type { DocLifecycleState } from './types/docgov';
|
||
import { RoleDefinition, DocVisibilityRule } from './types/rbac';
|
||
import {
|
||
DEFAULT_ROLES,
|
||
hasPermission,
|
||
filterVisibleDeliverablesWithRBAC,
|
||
} from './engine/rbac';
|
||
import {
|
||
applyPlanViewToCard,
|
||
cardFromPlanView,
|
||
} from './engine/planCard';
|
||
import {
|
||
fetchActiveIntentPlan,
|
||
} from './api/intentPlans';
|
||
import {
|
||
ALL_INITIAL_DELIVERABLES,
|
||
ALL_MOCK_DOCUMENTS,
|
||
MOCK_USERS,
|
||
} from '@demo-seeds';
|
||
import {
|
||
getTemplateDefinitionByTaskKey,
|
||
buildTemplateASTForProject,
|
||
} from './engine/templateFactory';
|
||
import {
|
||
loadSessions,
|
||
loadActiveSessionId,
|
||
loadDeliverables,
|
||
loadUsers,
|
||
purgeDemoSeedsFromLocalStorage,
|
||
resolveActiveSessionForUser,
|
||
loadProjects,
|
||
filterProjectSessionsForUser,
|
||
getProjectMembership,
|
||
resetWorkbenchStorage,
|
||
adoptWorkbenchUser,
|
||
} from './data/storage';
|
||
import { useChatWindow } from './hooks/useChatWindow';
|
||
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
|
||
import { useBlockEditing } from './hooks/useBlockEditing';
|
||
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
|
||
import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions';
|
||
import { useFreeformDocActions } from './hooks/useFreeformDocActions';
|
||
import { useCreateProjectConfirm } from './hooks/useCreateProjectConfirm';
|
||
import { useProjectMemberActions } from './hooks/useProjectMemberActions';
|
||
import { useCardActions } from './hooks/useCardActions';
|
||
import { useTaskRunAttempts } from './hooks/useTaskRunAttempts';
|
||
import { useSendMessage } from './hooks/useSendMessage';
|
||
import { useServerDocumentSync } from './hooks/useServerDocumentSync';
|
||
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
|
||
import { useTurnRuntime } from './hooks/useTurnRuntime';
|
||
import { useTaskRunStream } from './hooks/useTaskRunStream';
|
||
import { mergeSessionUser } from './lib/mergeSessionUser';
|
||
import { RequestEpoch, shareInflight } from './data/initFanout';
|
||
import {
|
||
resolveSessionProjectId,
|
||
} from './lib/sessionAdapter';
|
||
import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator';
|
||
import { useUnreadMarks } from './hooks/useUnreadMarks';
|
||
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
|
||
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
|
||
import { saveFile } from './data/fileStore';
|
||
import { cancelTaskRun, getTaskRun } from './api/taskRuns';
|
||
import { cancelInflightAssistantMessages } from './lib/squadCompletionObservation';
|
||
import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases';
|
||
|
||
export {
|
||
ALL_INITIAL_DELIVERABLES,
|
||
ALL_MOCK_DOCUMENTS,
|
||
MOCK_USERS,
|
||
} from '@demo-seeds';
|
||
|
||
const loadAdminConsoleView = () =>
|
||
import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView }));
|
||
export default function App() {
|
||
// Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*)
|
||
purgeDemoSeedsFromLocalStorage();
|
||
|
||
// 会话与用户状态 (支持 localStorage 刷新持久化)
|
||
const [sessions, setSessions] = useState<ProjectSession[]>(() => loadSessions());
|
||
// demo seeds 开启时由 NEUROPULSE_ACTIVE_SESSION_ID 提供 fallback;关闭时为空,避免对不存在会话发请求
|
||
const [activeSessionId, setActiveSessionId] = useState<string>(() => loadActiveSessionId());
|
||
|
||
// 全局角色库与人员名单状态 (支持动态增删改与持久化)
|
||
const [roles, setRoles] = useState<RoleDefinition[]>(() => DEFAULT_ROLES);
|
||
const [allUsers, setAllUsers] = useState<UserProfile[]>(() => loadUsers(MOCK_USERS));
|
||
|
||
// 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责)
|
||
const [currentUser, setCurrentUser] = useState<UserProfile>(() => allUsers[0] || MOCK_USERS[0] || {
|
||
id: 0,
|
||
name: '',
|
||
employeeId: '',
|
||
department: '',
|
||
position: '',
|
||
roleId: 'role-guest',
|
||
projectPermissions: {},
|
||
});
|
||
|
||
// 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类,支持持久化)
|
||
const [deliverables, setDeliverables] = useState<DeliverableItem[]>(() =>
|
||
loadDeliverables(ALL_INITIAL_DELIVERABLES)
|
||
);
|
||
const deliverablesRef = useRef(deliverables);
|
||
deliverablesRef.current = deliverables;
|
||
|
||
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
|
||
|
||
// 项目与成员体系状态 (支持持久化)
|
||
const [projects, setProjects] = useState<ProjectInfo[]>(() => loadProjects());
|
||
const projectsRef = useRef(projects);
|
||
projectsRef.current = projects;
|
||
const memberInflightRef = useRef(new Map<number, Promise<ProjectMember[] | null>>());
|
||
const sessionEpochRef = useRef(new RequestEpoch());
|
||
useWorkbenchPersistence({
|
||
sessions,
|
||
activeSessionId,
|
||
allUsers,
|
||
deliverables,
|
||
deliverablesRef,
|
||
setDeliverables,
|
||
projects,
|
||
});
|
||
|
||
const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false);
|
||
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
|
||
/** 可见提交态(禁用确认/关闭);与同步 in-flight ref 配合防双击 */
|
||
const [createProjectSubmitting, setCreateProjectSubmitting] = useState(false);
|
||
const [createProjectSubmitPhase, setCreateProjectSubmitPhase] =
|
||
useState<CreateProjectSubmitPhase | null>(null);
|
||
const createProjectInFlightRef = useRef(false);
|
||
/**
|
||
* create 成功但成员失败时保留的 project id(同步 ref,禁止用 React state 做并发门禁)。
|
||
* create 成功即写入;全流程成功后清空;失败后串行重试跳过再次 create。
|
||
*/
|
||
const createProjectBootstrapIdRef = useRef<number | null>(null);
|
||
const [projectSyncNotice, setProjectSyncNotice] = useState<string | null>(null);
|
||
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
|
||
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
|
||
const [projectMembersError, setProjectMembersError] = useState<string | null>(null);
|
||
const [workflowDocsEpoch, setWorkflowDocsEpoch] = useState(0);
|
||
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
|
||
const [workflowGovernanceEnabled, setWorkflowGovernanceEnabled] = useState(false);
|
||
|
||
const isAdmin = currentUser.roleId === 'role-admin';
|
||
|
||
// 获取当前激活会话的项目ID;无有效会话时为 0(空态由 chatBlockedNoProject 分支承接,
|
||
// 不得回退到硬编码项目 ID——真实环境中该 ID 可能不存在,会产生 404/403 假请求)
|
||
const currentProjectId = useMemo(() => {
|
||
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
|
||
const resolved = resolveSessionProjectId(activeSess);
|
||
return resolved > 0 ? resolved : 0;
|
||
}, [sessions, activeSessionId]);
|
||
|
||
// 获取当前项目与当前用户在其中的成员职责
|
||
const currentProject = useMemo(() => {
|
||
return projects.find((p) => p.id === currentProjectId);
|
||
}, [projects, currentProjectId]);
|
||
|
||
const currentMembership = useMemo(() => {
|
||
return getProjectMembership(currentProject, currentUser.id);
|
||
}, [currentProject, currentUser.id]);
|
||
|
||
/** deny_only_submit:活跃成员可 drafting 编辑;exited/非成员不可。 */
|
||
const isActiveProjectMember = useMemo(() => {
|
||
return !!currentMembership && currentMembership.status === 'active';
|
||
}, [currentMembership]);
|
||
|
||
const applyMembersToProject = useCallback((projectId: number, members: ProjectMember[]) => {
|
||
setProjects((prev) => prev.map((p) => (p.id === projectId ? { ...p, members } : p)));
|
||
}, []);
|
||
|
||
const syncProjectMembersFromServer = useCallback(
|
||
async (projectId: number, creatorId: number) => {
|
||
return shareInflight(memberInflightRef.current, projectId, async () => {
|
||
try {
|
||
const records = await listProjectMembers(projectId);
|
||
const members = mapApiMembersToProjectMembers(records, creatorId);
|
||
if (members.length > 0) {
|
||
applyMembersToProject(projectId, members);
|
||
}
|
||
setProjectMembersError(null);
|
||
return members;
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : '项目成员同步失败';
|
||
setProjectMembersError(msg);
|
||
return null;
|
||
}
|
||
});
|
||
},
|
||
[applyMembersToProject]
|
||
);
|
||
|
||
const isCurrentProjectExited = currentMembership?.status === 'exited';
|
||
const chatBlockedNoProject = !currentProjectId || currentProjectId <= 0 || projects.length === 0;
|
||
|
||
// 当前用户专属的项目会话列表 (基于项目成员身份与退出留痕过滤)
|
||
const visibleSessions = useMemo(() => {
|
||
return filterProjectSessionsForUser(sessions, projects, currentUser.id, isAdmin);
|
||
}, [sessions, projects, currentUser.id, isAdmin]);
|
||
|
||
// 账号变动或项目退出时,确保当前激活的会话属于当前用户可见范围
|
||
useEffect(() => {
|
||
const cur = visibleSessions.find((s) => s.sessionId === activeSessionId);
|
||
if (!cur && visibleSessions.length > 0) {
|
||
const nextActiveId = resolveActiveSessionForUser(
|
||
visibleSessions,
|
||
currentUser.id,
|
||
currentProjectId,
|
||
activeSessionId
|
||
);
|
||
setActiveSessionId(nextActiveId || visibleSessions[0].sessionId);
|
||
}
|
||
}, [currentUser.id, visibleSessions, currentProjectId, activeSessionId]);
|
||
|
||
const [activeDocId, setActiveDocId] = useState<string>(() => {
|
||
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
|
||
const targetProjId = activeSess ? activeSess.projectId : currentProjectId;
|
||
const projDocs = deliverables.filter((d) => d.projectId === targetProjId);
|
||
if (projDocs.length > 0) {
|
||
return projDocs[0].docId;
|
||
}
|
||
if (targetProjId === 5201) return 'doc-neuropulse-feasibility_reg';
|
||
if (targetProjId === 3) return 'doc-cusa-feasibility-reg';
|
||
return deliverables[0]?.docId || 'doc-001';
|
||
});
|
||
const activeDocIdRef = useRef(activeDocId);
|
||
activeDocIdRef.current = activeDocId;
|
||
const [docRevOverlayMode, setDocRevOverlayMode] = useState<OverlayMode>('manual');
|
||
const [docRevOverlay, setDocRevOverlay] = useState<DiffResult | null>(null);
|
||
const [docRevConflict, setDocRevConflict] = useState<string | null>(null);
|
||
/** 人工保存 / 受控正文 hydrate 后递增,驱动工具条重新拉取 revision-state 与 overlay */
|
||
const [docRevRefreshKey, setDocRevRefreshKey] = useState(0);
|
||
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
|
||
const editorHydrationGateRef = useRef(createEditorHydrationGate());
|
||
const [hydrateResumeKey, setHydrateResumeKey] = useState(0);
|
||
const [editorSession, setEditorSession] = useState<BlockEditorSession | null>(null);
|
||
|
||
const openBlockEditor = useCallback((block: DocumentBlock) => {
|
||
editorHydrationGateRef.current.setEditorOpen(true);
|
||
setEditorSession({ docId: activeDocId, block });
|
||
}, [activeDocId]);
|
||
|
||
const closeBlockEditor = useCallback(() => {
|
||
const resume = editorHydrationGateRef.current.takeHydrationResume();
|
||
editorHydrationGateRef.current.setEditorOpen(false);
|
||
setEditorSession(null);
|
||
if (resume) setHydrateResumeKey((k) => k + 1);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
editorHydrationGateRef.current.setActiveDocId(activeDocId);
|
||
}, [activeDocId]);
|
||
|
||
// 认证与会话状态
|
||
const [session, setSession] = useState<LoginResult | null>(() => authClient.loadSavedSession());
|
||
const [sessionValidated, setSessionValidated] = useState<boolean | null>(() =>
|
||
authClient.loadSavedSession() ? null : false,
|
||
);
|
||
const [systemEnv, setSystemEnv] = useState<string>('');
|
||
const [systemAuthConfig, setSystemAuthConfig] = useState<SystemAuthConfig>({
|
||
allowRegistration: true,
|
||
requireApproval: false,
|
||
});
|
||
|
||
// 模态浮层控制
|
||
const [rightsModalOpen, setRightsModalOpen] = useState(false);
|
||
const [userSettingsOpen, setUserSettingsOpen] = useState(false);
|
||
// 顶层视图切换:单屏工作台 / 全屏系统管理台(仅管理员可进入)
|
||
const [view, setView] = useState<'workbench' | 'admin'>('workbench');
|
||
const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false);
|
||
const [targetDocForVisibility, setTargetDocForVisibility] = useState<DeliverableItem | null>(null);
|
||
|
||
const [capabilities, setCapabilities] = useState<string[]>(() => authClient.getCapabilities());
|
||
|
||
useAuthenticatedWorkspaceSync({
|
||
allUsers,
|
||
setAllUsers,
|
||
setCurrentUser,
|
||
setSession,
|
||
setSessionValidated,
|
||
setSystemEnv,
|
||
setSystemAuthConfig,
|
||
setCapabilities,
|
||
session,
|
||
sessionValidated,
|
||
currentUser,
|
||
currentProjectId,
|
||
syncProjectMembersFromServer,
|
||
setProjects,
|
||
setProjectSyncNotice,
|
||
projectMembersModalOpen,
|
||
projectsRef,
|
||
setSessions,
|
||
setSessionSyncNotice,
|
||
sessionEpochRef,
|
||
});
|
||
|
||
// handleLogout 定义在 turnRuntime/taskRunStream hook 之后,便于 abort 全会话在途流。
|
||
|
||
// 核心功能权限计算
|
||
const canCreateProject = useMemo(() => {
|
||
return hasPermission(currentUser, roles, 'project:create', capabilities);
|
||
}, [currentUser, roles, capabilities]);
|
||
|
||
const canAssignLead = useMemo(() => {
|
||
return hasPermission(currentUser, roles, 'project:assign_lead', capabilities);
|
||
}, [currentUser, roles, capabilities]);
|
||
|
||
const canOverrideVisibility = useMemo(() => {
|
||
return (
|
||
hasPermission(currentUser, roles, 'doc:override_visibility', capabilities) ||
|
||
hasPermission(currentUser, roles, 'perm:manage', capabilities)
|
||
);
|
||
}, [currentUser, roles, capabilities]);
|
||
|
||
const canManageProjectMembers = useMemo(() => {
|
||
if (!currentProject) return false;
|
||
if (hasPermission(currentUser, roles, 'perm:manage', capabilities)) return true;
|
||
if (hasPermission(currentUser, roles, 'project:manage_members', capabilities)) return true;
|
||
if (currentUser.id === currentProject.creatorId) return true;
|
||
const membership = currentProject.members.find((m) => m.userId === currentUser.id);
|
||
return membership?.role === 'creator' || membership?.role === 'owner';
|
||
}, [currentProject, currentUser, roles, capabilities]);
|
||
|
||
// 项目多工作流激活状态与文档血缘 (支持前端切换与状态联动)
|
||
const [projectWorkflowsState, setProjectWorkflowsState] = useState<
|
||
Record<number, { activeKey: string; version: number; creatorId: number; lifecycleStatus: string }>
|
||
>({});
|
||
const [workflowScopeMode, setWorkflowScopeMode] = useState<'strict' | 'weighted'>('strict');
|
||
const [projectLineages, setProjectLineages] = useState<DocumentLineage[]>([]);
|
||
|
||
const currentActiveSession =
|
||
sessions.find((s) => s.sessionId === activeSessionId) || sessions[0];
|
||
const currentWfState = projectWorkflowsState[currentProjectId] || {
|
||
activeKey: '',
|
||
version: 1,
|
||
creatorId: currentActiveSession?.userId || currentUser.id || 2,
|
||
lifecycleStatus: 'planning',
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || !currentProjectId) return;
|
||
if (currentProjectId) {
|
||
getProject(currentProjectId)
|
||
.then((p) => {
|
||
if (p) {
|
||
setProjectWorkflowsState((prev) => ({
|
||
...prev,
|
||
[currentProjectId]: {
|
||
activeKey: p.active_workflow_key || '',
|
||
version: p.workflow_selection_version || 1,
|
||
creatorId: p.creator_user_id || 1,
|
||
lifecycleStatus: p.lifecycle_status || (p.active_workflow_key ? 'active' : 'planning'),
|
||
},
|
||
}));
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
fetchProjectSettings(currentProjectId)
|
||
.then(() => setWorkflowGovernanceEnabled(true))
|
||
.catch(() => setWorkflowGovernanceEnabled(false));
|
||
}
|
||
}, [session, sessionValidated, currentProjectId]);
|
||
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || !currentProjectId) return;
|
||
if (currentProjectId) {
|
||
fetchProjectLineage(currentProjectId)
|
||
.then((lins) => setProjectLineages(lins))
|
||
.catch(() => {});
|
||
}
|
||
}, [session, sessionValidated, currentProjectId, currentWfState.activeKey]);
|
||
|
||
const workflowBanner = (
|
||
<>
|
||
<MaintenanceBanner />
|
||
{(projectSyncNotice || sessionSyncNotice) && (
|
||
<div
|
||
style={{
|
||
padding: '8px 12px',
|
||
margin: '0 12px 8px',
|
||
borderRadius: '8px',
|
||
background: 'rgba(245, 158, 11, 0.12)',
|
||
border: '1px solid rgba(245, 158, 11, 0.35)',
|
||
color: '#b45309',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
{[projectSyncNotice, sessionSyncNotice].filter(Boolean).join(' ')}
|
||
</div>
|
||
)}
|
||
{workflowGovernanceEnabled && currentWfState.lifecycleStatus === 'active' && (
|
||
<StageAdvanceBanner
|
||
projectId={currentProjectId}
|
||
isCreator={currentWfState.creatorId === currentUser.id}
|
||
/>
|
||
)}
|
||
<StaleDocumentNotice
|
||
lineages={projectLineages}
|
||
onTriggerRecheck={() => {
|
||
fetchProjectLineage(currentProjectId).then(setProjectLineages).catch(() => {});
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
|
||
// 严格按 projectId 隔离当前项目的交付物
|
||
const currentProjectDeliverables = useMemo(() => {
|
||
return deliverables.filter((doc) => doc.projectId === currentProjectId);
|
||
}, [deliverables, currentProjectId]);
|
||
|
||
// 基于当前登录用户与 RBAC 规则过滤可见交付物 (私密文档严格按 creatorId 隔离,单文档覆盖优先于工作流基线)
|
||
const visibleDeliverables = useMemo(() => {
|
||
return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles);
|
||
}, [currentProjectDeliverables, currentUser, roles]);
|
||
|
||
// 单文档可见性覆盖保存处理
|
||
const handleSaveDocVisibilityRule = (docId: string, rule: DocVisibilityRule | undefined) => {
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === docId ? { ...d, visibilityRule: rule } : d))
|
||
);
|
||
setDocCache((prev) => {
|
||
if (!prev[docId]) return prev;
|
||
return {
|
||
...prev,
|
||
[docId]: {
|
||
...prev[docId],
|
||
visibilityRule: rule,
|
||
},
|
||
};
|
||
});
|
||
};
|
||
|
||
// 当用户切换或可见列表变动时,自动校准激活文档
|
||
useEffect(() => {
|
||
const isCurrentVisible = visibleDeliverables.some((d) => d.docId === activeDocId);
|
||
if (!isCurrentVisible) {
|
||
const fallback =
|
||
visibleDeliverables.find((d) => d.category === activeCategory) ||
|
||
visibleDeliverables[0];
|
||
if (fallback) {
|
||
setActiveDocId(fallback.docId);
|
||
setActiveCategory(fallback.category);
|
||
}
|
||
}
|
||
}, [currentUser.id, visibleDeliverables, activeDocId, activeCategory]);
|
||
|
||
// 文档缓存与当前 AST(demo 种子仅在 VITE_ENABLE_DEMO_SEEDS=true 时进入 ALL_MOCK_DOCUMENTS)
|
||
const [docCache, setDocCache] = useState<Record<string, DocumentAST>>(() => {
|
||
const initialCache: Record<string, DocumentAST> = {};
|
||
Object.entries(ALL_MOCK_DOCUMENTS).forEach(([id, meta]) => {
|
||
const rawText = meta.markdown || (meta as { content?: string }).content || '';
|
||
initialCache[id] = markdownToBlockAST(rawText, {
|
||
docId: meta.docId || id,
|
||
title: meta.title || '',
|
||
taskKey: meta.taskKey || 'system_reqs',
|
||
version: meta.version || 'V1.0',
|
||
category: (meta.category as DeliverableCategory | undefined) || 'workflow',
|
||
creatorId: meta.creatorId || 1,
|
||
creatorName: meta.creatorName || '系统管理员',
|
||
sharedAt: meta.sharedAt,
|
||
lifecycleState: (meta.lifecycleState as DocLifecycleState | undefined) || 'drafting',
|
||
isClaimed: meta.isClaimed ?? true,
|
||
author: meta.author || '',
|
||
reviewer: meta.reviewer || '',
|
||
approver: meta.approver || '',
|
||
});
|
||
});
|
||
|
||
// 自动为持久化存储的自定义项目受控模板预热补全完整高质量 AST,杜绝刷新后丢失
|
||
try {
|
||
const savedDeliverables = loadDeliverables(ALL_INITIAL_DELIVERABLES);
|
||
const savedProjects = loadProjects();
|
||
savedDeliverables.forEach((item) => {
|
||
if (item.docId && !initialCache[item.docId] && item.taskKey && item.isTemplateOnly !== false) {
|
||
const tmpl = getTemplateDefinitionByTaskKey(item.taskKey);
|
||
if (tmpl) {
|
||
const proj = savedProjects.find((p) => p.id === item.projectId);
|
||
initialCache[item.docId] = buildTemplateASTForProject(tmpl, {
|
||
projectId: item.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: item.docId,
|
||
creatorId: item.creatorId || 1,
|
||
creatorName: item.creatorName || '系统',
|
||
});
|
||
}
|
||
}
|
||
});
|
||
} catch {
|
||
// 忽略预热异常
|
||
}
|
||
|
||
return initialCache;
|
||
});
|
||
|
||
const [controlledContentReady, setControlledContentReady] = useState<Record<string, boolean>>({});
|
||
const applyControlledInvalidationPlan = useCallback((plan: ControlledInvalidationPlan) => {
|
||
if (plan.dropIds.length > 0) {
|
||
setDocCache((cache) => dropRecordKeys(cache, plan.dropIds));
|
||
setControlledContentReady((ready) => dropRecordKeys(ready, plan.dropIds));
|
||
}
|
||
if (plan.requestResume) {
|
||
editorHydrationGateRef.current.requestHydrationResume();
|
||
}
|
||
if (plan.rehydrateActive) {
|
||
setHydrateResumeKey((k) => k + 1);
|
||
}
|
||
}, []);
|
||
const [docLoadErrors, setDocLoadErrors] = useState<Record<string, boolean>>({});
|
||
const [docLoadErrorInfo, setDocLoadErrorInfo] = useState<
|
||
Record<string, { code?: string; message: string; correlationId?: string }>
|
||
>({});
|
||
const activeHydrateFingerprint = useMemo(() => {
|
||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||
return target ? controlledHydrateFingerprint(target) : '';
|
||
}, [deliverables, activeDocId]);
|
||
|
||
const docAST = useMemo<DocumentAST>(() => {
|
||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||
const needsServerContent = target ? shouldLoadControlledContent(target) : false;
|
||
|
||
// 1. 命中缓存(受控非模板须等服务端正文拉取完成)
|
||
if (docCache[activeDocId]) {
|
||
if (!needsServerContent || controlledContentReady[activeDocId]) {
|
||
return docCache[activeDocId];
|
||
}
|
||
}
|
||
// 2. 若缓存缺失,基于目标 deliverable 实时自愈高质量 AST
|
||
if (target) {
|
||
if (needsServerContent) {
|
||
return buildControlledLoadingSkeleton(target);
|
||
}
|
||
const proj = projects.find((p) => p.id === target.projectId);
|
||
const tmpl = target.taskKey ? getTemplateDefinitionByTaskKey(target.taskKey) : null;
|
||
if (tmpl && target.isTemplateOnly !== false) {
|
||
return buildTemplateASTForProject(tmpl, {
|
||
projectId: target.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: target.docId,
|
||
creatorId: target.creatorId || 1,
|
||
creatorName: target.creatorName || '系统',
|
||
});
|
||
}
|
||
|
||
// 关键修复:对于用户上传文件或自由研讨文档,原地自愈生成 AST,绝不跳至第一份工作流文档
|
||
const skeleton = `# ${target.title}\n\n- 交付物阶段:${target.stage || 'V0.5'}\n- 归属分类:${target.category === 'private' ? '个人私密' : target.category === 'shared' ? '项目共享' : '流程受控'}\n- 上传/编制人:${target.author || target.creatorName || '本人'}\n- 更新时间:${target.updatedAt || new Date().toISOString().substring(0, 10)}\n\n## 1. 文档概述与基本信息\n${target.summary || '用户上传文档或外部参考资料。'}\n`;
|
||
const fallbackAST = markdownToBlockAST(skeleton, {
|
||
docId: target.docId,
|
||
title: target.title,
|
||
taskKey: target.taskKey || 'user_requirements',
|
||
version: target.stage || 'V0.5',
|
||
category: target.category,
|
||
creatorId: target.creatorId,
|
||
creatorName: target.creatorName,
|
||
lifecycleState: target.lifecycleState || 'drafting',
|
||
isClaimed: target.isClaimed ?? true,
|
||
author: target.author || target.creatorName || '本人',
|
||
authorId: target.authorId,
|
||
reviewer: target.reviewer || '',
|
||
reviewerId: target.reviewerId,
|
||
approver: target.approver || '',
|
||
approverId: target.approverId,
|
||
});
|
||
fallbackAST.governanceMode = target.governanceMode || 'freeform';
|
||
fallbackAST.space = target.space || (target.category === 'shared' ? 'project_shared' : 'my_private');
|
||
return fallbackAST;
|
||
}
|
||
// 3. 当前项目第一个可见交付物
|
||
const firstProjDoc = visibleDeliverables[0];
|
||
if (firstProjDoc && docCache[firstProjDoc.docId]) {
|
||
return docCache[firstProjDoc.docId];
|
||
}
|
||
if (firstProjDoc) {
|
||
if (shouldLoadControlledContent(firstProjDoc)) {
|
||
return buildControlledLoadingSkeleton(firstProjDoc);
|
||
}
|
||
const proj = projects.find((p) => p.id === firstProjDoc.projectId);
|
||
const tmpl = firstProjDoc.taskKey ? getTemplateDefinitionByTaskKey(firstProjDoc.taskKey) : null;
|
||
if (tmpl && firstProjDoc.isTemplateOnly !== false) {
|
||
return buildTemplateASTForProject(tmpl, {
|
||
projectId: firstProjDoc.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: firstProjDoc.docId,
|
||
creatorId: firstProjDoc.creatorId || 1,
|
||
creatorName: firstProjDoc.creatorName || '系统',
|
||
});
|
||
}
|
||
}
|
||
// 4. 极端兜底(无 demo seed / 空缓存时仍须返回合法 AST,避免登录页崩溃)
|
||
const cached = docCache['doc-neuropulse-feasibility_reg'] || docCache['doc-001'] || Object.values(docCache)[0];
|
||
if (cached) return cached;
|
||
return markdownToBlockAST('# 工作台\n\n请选择或创建项目后继续。\n', {
|
||
docId: 'doc-empty-workbench',
|
||
title: '工作台',
|
||
taskKey: 'workbench',
|
||
version: 'V0.0',
|
||
category: 'private',
|
||
creatorId: currentUser?.id || 0,
|
||
creatorName: currentUser?.name || '用户',
|
||
lifecycleState: 'drafting',
|
||
isClaimed: false,
|
||
author: currentUser?.name || '用户',
|
||
authorId: currentUser?.id || 0,
|
||
});
|
||
}, [docCache, activeDocId, deliverables, projects, visibleDeliverables, controlledContentReady, currentUser]);
|
||
|
||
const { applyHydratedDocument, retryControlledDocLoad } = useControlledDocumentHydration({
|
||
session,
|
||
sessionValidated,
|
||
activeDocId,
|
||
setActiveDocId,
|
||
deliverables,
|
||
docCache,
|
||
setDocCache,
|
||
setControlledContentReady,
|
||
setDocLoadErrors,
|
||
setDocLoadErrorInfo,
|
||
setDocRevRefreshKey,
|
||
editorHydrationGateRef,
|
||
activeHydrateFingerprint,
|
||
hydrateResumeKey,
|
||
docAST,
|
||
});
|
||
|
||
// 根据权威 user id 计算当前文档职责;未知身份 fail-closed 为 viewer
|
||
const currentDocRole: UserRole = resolveCurrentDocRole({
|
||
currentUser,
|
||
authorId: docAST.authorId,
|
||
reviewerId: docAST.reviewerId,
|
||
approverId: docAST.approverId,
|
||
author: docAST.author,
|
||
reviewer: docAST.reviewer,
|
||
approver: docAST.approver,
|
||
projectPermission: currentUser.projectPermissions?.[activeDocId],
|
||
dutiesAuthoritative: docAST.dutiesAuthoritative,
|
||
governanceMode: docAST.governanceMode,
|
||
isLegacyLocalDoc:
|
||
docAST.dutiesAuthoritative !== true &&
|
||
docAST.governanceMode !== 'controlled' &&
|
||
/^doc-p\d+-/.test(docAST.docId),
|
||
});
|
||
|
||
const setDocAST = (newAst: DocumentAST) => {
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
[activeDocId]: newAst,
|
||
}));
|
||
};
|
||
|
||
// 切换文档
|
||
const handleSelectDoc = (id: string) => {
|
||
const switchDecision = decideActiveDocSwitch({
|
||
editorDocId: editorSession?.docId,
|
||
nextDocId: id,
|
||
});
|
||
if (!switchDecision.allow) {
|
||
setDocRevConflict(switchDecision.message);
|
||
return;
|
||
}
|
||
editorHydrationGateRef.current.setActiveDocId(id);
|
||
expandRightDocRail();
|
||
setActiveDocId(id);
|
||
let targetDoc = deliverables.find((d) => d.docId === id);
|
||
|
||
// 关键自愈:若该文档不在全局 deliverables 中(例如历史消息中的附件),自动从消息池中寻回并自愈注册
|
||
if (!targetDoc) {
|
||
for (const msg of messages) {
|
||
if (msg.attachments) {
|
||
const matchAtt = msg.attachments.find((att) => att.docId === id);
|
||
if (matchAtt) {
|
||
const extMatch = matchAtt.name.match(/\.([^.]+)$/);
|
||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||
const baseTitle = matchAtt.name.replace(/\.[^/.]+$/, '');
|
||
const recovered: DeliverableItem = {
|
||
docId: id,
|
||
projectId: currentProjectId,
|
||
taskKey: 'user_requirements',
|
||
title: baseTitle,
|
||
stage: 'V0.5',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '',
|
||
approver: '',
|
||
updatedAt: new Date().toISOString().substring(0, 10),
|
||
summary: `用户上传的私密需求文档《${matchAtt.name}》,归档至个人私密文档库。`,
|
||
space: 'my_private',
|
||
governanceMode: 'freeform',
|
||
sourceType: 'uploaded_file',
|
||
fileMeta: {
|
||
originalName: matchAtt.name,
|
||
size: matchAtt.size,
|
||
mimeType: matchAtt.type,
|
||
extension: ext,
|
||
fileRaw: matchAtt.fileRaw,
|
||
fileUrl: matchAtt.fileUrl,
|
||
uploadedAt: new Date().toISOString(),
|
||
},
|
||
};
|
||
targetDoc = recovered;
|
||
setDeliverables((prev) => [recovered, ...prev.filter((d) => d.docId !== id)]);
|
||
// ✅ 自愈注册时同步持久化文件内容到 IndexedDB(若 fileRaw 有效)
|
||
if (matchAtt.fileRaw) {
|
||
saveFile(id, matchAtt.fileRaw, matchAtt.name).catch(() => {});
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (targetDoc) {
|
||
setActiveCategory(targetDoc.category);
|
||
}
|
||
|
||
if (targetDoc && shouldLoadControlledContent(targetDoc)) {
|
||
const token = editorHydrationGateRef.current.beginHydration(id);
|
||
if (!token) return;
|
||
void hydrateControlledDocumentContent(id, targetDoc)
|
||
.then((ast) => {
|
||
applyHydratedDocument(token, ast, id, { refreshToolbar: false });
|
||
})
|
||
.catch((err) => {
|
||
console.warn('获取受控文档正文失败:', err);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!docCache[id]) {
|
||
if (ALL_MOCK_DOCUMENTS[id]) {
|
||
const meta = ALL_MOCK_DOCUMENTS[id];
|
||
const parsed = markdownToBlockAST(meta.markdown || '', {
|
||
docId: meta.docId || id,
|
||
title: meta.title,
|
||
taskKey: meta.taskKey,
|
||
version: meta.version,
|
||
category: meta.category as DeliverableCategory | undefined,
|
||
creatorId: meta.creatorId,
|
||
creatorName: meta.creatorName,
|
||
sharedAt: meta.sharedAt,
|
||
lifecycleState: meta.lifecycleState as DocLifecycleState | undefined,
|
||
isClaimed: meta.isClaimed,
|
||
author: meta.author,
|
||
reviewer: meta.reviewer,
|
||
approver: meta.approver,
|
||
});
|
||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||
} else if (targetDoc) {
|
||
// 关键升级:若为工作流标准模板,必须以工业级规格生成完备待填报模板 AST,坚决杜绝 3 行摘要残缺占位符
|
||
const tmpl = targetDoc.taskKey ? getTemplateDefinitionByTaskKey(targetDoc.taskKey) : null;
|
||
if (tmpl && targetDoc.isTemplateOnly !== false) {
|
||
const proj = projects.find((p) => p.id === targetDoc.projectId);
|
||
const fullAst = buildTemplateASTForProject(tmpl, {
|
||
projectId: targetDoc.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: targetDoc.docId,
|
||
creatorId: targetDoc.creatorId || 1,
|
||
creatorName: targetDoc.creatorName || '系统',
|
||
});
|
||
setDocCache((prev) => ({ ...prev, [id]: fullAst }));
|
||
} else {
|
||
// 仅当非受控模板时才使用普通草稿骨架
|
||
const skeleton = `# ${targetDoc.title}\n\n- 交付物阶段:${targetDoc.stage}\n- 归属分类:${targetDoc.category}\n- 编制人:${targetDoc.author || targetDoc.creatorName}\n\n## 1. 交付物摘要\n${targetDoc.summary || '暂无详细章节摘要,待智能体参谋与项目成员增补。'}\n`;
|
||
const parsed = markdownToBlockAST(skeleton, {
|
||
docId: targetDoc.docId,
|
||
title: targetDoc.title,
|
||
taskKey: targetDoc.taskKey,
|
||
version: targetDoc.stage,
|
||
category: targetDoc.category,
|
||
creatorId: targetDoc.creatorId,
|
||
creatorName: targetDoc.creatorName,
|
||
lifecycleState: targetDoc.lifecycleState,
|
||
isClaimed: targetDoc.isClaimed,
|
||
author: targetDoc.author,
|
||
reviewer: targetDoc.reviewer,
|
||
approver: targetDoc.approver,
|
||
});
|
||
parsed.governanceMode = targetDoc.governanceMode;
|
||
parsed.space = targetDoc.space;
|
||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||
}
|
||
|
||
if (id.startsWith('doc-free-') || targetDoc.governanceMode === 'freeform') {
|
||
fetchFreeformDoc(id)
|
||
.then((detail) => {
|
||
if (detail && detail.content_markdown) {
|
||
const fullAST = markdownToBlockAST(detail.content_markdown, {
|
||
docId: detail.document.doc_id,
|
||
title: detail.document.title,
|
||
taskKey: targetDoc.taskKey,
|
||
version: `${detail.document.current_version_id || 1}`,
|
||
category: targetDoc.category,
|
||
creatorId: detail.document.owner_user_id,
|
||
creatorName: currentUser.name,
|
||
lifecycleState: targetDoc.lifecycleState,
|
||
isClaimed: true,
|
||
author: targetDoc.author,
|
||
reviewer: targetDoc.reviewer,
|
||
approver: targetDoc.approver,
|
||
});
|
||
fullAST.governanceMode = 'freeform';
|
||
fullAST.space = detail.document.space;
|
||
setDocCache((prev) => ({ ...prev, [id]: fullAST }));
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.warn('获取自由文档详情失败,保持本地草稿骨架:', err);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
useServerDocumentSync({
|
||
session,
|
||
sessionValidated,
|
||
currentProjectId,
|
||
currentUser,
|
||
workflowDocsEpoch,
|
||
setDeliverables,
|
||
setDocCache,
|
||
deliverablesRef,
|
||
activeDocIdRef,
|
||
editorHydrationGateRef,
|
||
applyControlledInvalidationPlan,
|
||
onWorkflowScopeMode: setWorkflowScopeMode,
|
||
});
|
||
|
||
// 模态弹窗与侧栏
|
||
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
|
||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
|
||
const [pendingProposal, setPendingProposal] = useState<{
|
||
proposalId: string;
|
||
changes: string;
|
||
impact: string;
|
||
risk: string;
|
||
patchBlockId?: string;
|
||
} | null>(null);
|
||
const [proposalError, setProposalError] = useState<string | null>(null);
|
||
|
||
// 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
|
||
const {
|
||
handleShareToProject,
|
||
handleRevokeShare,
|
||
handleDeleteDoc,
|
||
handleSaveMessageToPrivate,
|
||
} = useFreeformDocActions({
|
||
activeDocId,
|
||
activeSessionId,
|
||
currentProjectId,
|
||
currentUser,
|
||
deliverables,
|
||
setDeliverables,
|
||
setDocCache,
|
||
setActiveDocId,
|
||
setActiveCategory,
|
||
setMessages,
|
||
setDeckDrawerOpen,
|
||
});
|
||
|
||
// 基于当前激活的项目会话严格隔离对话记录
|
||
const currentSessionMessages = useMemo(() => {
|
||
return messages.filter((m) => m.sessionId === activeSessionId);
|
||
}, [messages, activeSessionId]);
|
||
|
||
// P0: 会话按日窗口状态机 (处理首屏拉取、瞬时落底、触顶加载补偿)
|
||
const authenticatedSessionId =
|
||
session && sessionValidated === true ? activeSessionId : '';
|
||
const turnRuntime = useTurnRuntime(authenticatedSessionId);
|
||
const taskRunStream = useTaskRunStream(authenticatedSessionId);
|
||
const isThinking = turnRuntime.isThinking || taskRunStream.isThinking;
|
||
|
||
const chatWindow = useChatWindow({
|
||
sessionId: authenticatedSessionId,
|
||
userId: currentUser.id,
|
||
liveMessages: currentSessionMessages,
|
||
});
|
||
|
||
// 刷新后仍以服务端 run 状态为准;终态出现时重拉窗口,恢复正文、错误动作和产物信息。
|
||
const recoverableRunIds = useMemo(
|
||
() =>
|
||
chatWindow.allMessages
|
||
.filter(
|
||
(message) =>
|
||
message.role === 'assistant' &&
|
||
message.status === 'streaming_status' &&
|
||
!!message.taskRunId,
|
||
)
|
||
.map((message) => message.taskRunId as string)
|
||
.sort(),
|
||
[chatWindow.allMessages],
|
||
);
|
||
const recoverableRunKey = recoverableRunIds.join(',');
|
||
useEffect(() => {
|
||
if (!recoverableRunKey) return;
|
||
let cancelled = false;
|
||
const skipped = new Set<string>();
|
||
// T20-F8: 同 runId 的 in-flight 请求合并,避免慢请求重叠造成指数级 getTaskRun。
|
||
type PolledRun = Awaited<ReturnType<typeof getTaskRun>>;
|
||
const inFlight = new Map<string, Promise<PolledRun | null>>();
|
||
const fetchRunOnce = (runId: string): Promise<PolledRun | null> => {
|
||
const existing = inFlight.get(runId);
|
||
if (existing) return existing;
|
||
const p = getTaskRun(runId)
|
||
.catch((err) => {
|
||
const status =
|
||
typeof err === 'object' && err !== null && ('httpStatus' in err || 'status' in err)
|
||
? Number((err as { httpStatus?: number; status?: number }).httpStatus ?? (err as { status?: number }).status)
|
||
: undefined;
|
||
// Stop hammering forbidden/missing runs (console 403 spam).
|
||
if (status === 403 || status === 404) {
|
||
skipped.add(runId);
|
||
}
|
||
// T20-F8: 终态(canceled/expired/failed 等)也不再轮询,扩展既有 skip 集。
|
||
return null;
|
||
})
|
||
.finally(() => {
|
||
inFlight.delete(runId);
|
||
});
|
||
inFlight.set(runId, p);
|
||
return p;
|
||
};
|
||
const poll = async () => {
|
||
const activeIds = recoverableRunIds.filter((id) => !skipped.has(id));
|
||
if (activeIds.length === 0) return;
|
||
const records = await Promise.all(activeIds.map((runId) => fetchRunOnce(runId)));
|
||
if (cancelled) return;
|
||
if (records.some((record) => record && record.status !== 'running')) {
|
||
await chatWindow.refresh();
|
||
}
|
||
};
|
||
void poll();
|
||
const timer = window.setInterval(() => void poll(), 3000);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
inFlight.clear();
|
||
};
|
||
}, [recoverableRunKey]);
|
||
|
||
useRecoveredTaskPhases(
|
||
authenticatedSessionId,
|
||
chatWindow.allMessages,
|
||
chatWindow.updateLiveMessage,
|
||
);
|
||
|
||
// P1: 多维度历史导航与分类统计、日历、引用状态
|
||
const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]);
|
||
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
|
||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||
const [isDigestOpen, setIsDigestOpen] = useState(false);
|
||
const [isExportOpen, setIsExportOpen] = useState(false);
|
||
const [activeQuotes, setActiveQuotes] = useState<QuotedMessage[]>([]);
|
||
|
||
// P3: 未读消息轮询与角标
|
||
const {
|
||
unreadMarks,
|
||
unreadTotal,
|
||
unreadMessageIds,
|
||
isAllReadCompleted,
|
||
consumeNextUnread,
|
||
} = useUnreadMarks(authenticatedSessionId, currentUser?.id);
|
||
|
||
// 会话切换时加载分类与日期统计
|
||
// T20-F8: stats 请求绑定 generation;快切会话时晚到的旧响应不得写入新会话的统计。
|
||
const statsGenRef = useRef(0);
|
||
useEffect(() => {
|
||
if (!activeSessionId || sessionValidated !== true) return;
|
||
const gen = ++statsGenRef.current;
|
||
fetchChatCategories(activeSessionId)
|
||
.then((cats) => {
|
||
if (gen !== statsGenRef.current) return;
|
||
setCategoryStats(cats || []);
|
||
})
|
||
.catch((err) => {
|
||
if (gen !== statsGenRef.current) return;
|
||
console.warn('Failed to load category stats:', err);
|
||
});
|
||
|
||
fetchChatDates(activeSessionId)
|
||
.then((dates) => {
|
||
if (gen !== statsGenRef.current) return;
|
||
setDateStats(dates || []);
|
||
})
|
||
.catch((err) => {
|
||
if (gen !== statsGenRef.current) return;
|
||
console.warn('Failed to load date stats:', err);
|
||
});
|
||
}, [activeSessionId, sessionValidated]);
|
||
|
||
// 刷新 / 重登:以 GET /intent-plans/active 覆盖本地 PLAN_CONFIRM(消息投影缺失仍可恢复)
|
||
useEffect(() => {
|
||
if (!authenticatedSessionId || currentProjectId <= 0) return;
|
||
const sessionId = authenticatedSessionId;
|
||
const projectId = currentProjectId;
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const plan = await fetchActiveIntentPlan(projectId, sessionId).catch(() => null);
|
||
if (cancelled) return;
|
||
const liveId = plan ? `plan-live-${plan.plan_id}` : '';
|
||
setMessages((prev) => {
|
||
const withoutStale = prev.filter(
|
||
(m) =>
|
||
!(
|
||
m.sessionId === sessionId &&
|
||
m.id.startsWith('plan-live-') &&
|
||
(!plan || m.id !== liveId)
|
||
),
|
||
);
|
||
if (!plan) return withoutStale;
|
||
const card = cardFromPlanView(plan);
|
||
const existing = withoutStale.find((m) => m.id === liveId);
|
||
if (existing?.interactiveCard) {
|
||
return withoutStale.map((m) =>
|
||
m.id === liveId
|
||
? { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard!, plan) }
|
||
: m,
|
||
);
|
||
}
|
||
return [
|
||
...withoutStale,
|
||
{
|
||
id: liveId,
|
||
sessionId,
|
||
role: 'assistant' as const,
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '计划确认',
|
||
content: plan.safe_summary || '请确认以下计划后再执行。',
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
interactiveCard: card,
|
||
},
|
||
];
|
||
});
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [authenticatedSessionId, currentProjectId]);
|
||
|
||
// 监听 URL 哈希 (#/s/{sessionId}/m/{messageId}) 实现深链接直接定位与脉冲动效
|
||
const activeSessionIdRef = useRef(activeSessionId);
|
||
useEffect(() => {
|
||
activeSessionIdRef.current = activeSessionId;
|
||
}, [activeSessionId]);
|
||
|
||
const loadDayForAnchorRef = useRef(chatWindow.loadDayForAnchor);
|
||
useEffect(() => {
|
||
loadDayForAnchorRef.current = chatWindow.loadDayForAnchor;
|
||
}, [chatWindow.loadDayForAnchor]);
|
||
|
||
const sessionValidatedRef = useRef(sessionValidated);
|
||
useEffect(() => {
|
||
sessionValidatedRef.current = sessionValidated;
|
||
}, [sessionValidated]);
|
||
|
||
useEffect(() => {
|
||
const handleHashChange = () => {
|
||
if (sessionValidatedRef.current !== true) return;
|
||
const parsed = parseAnchorHash(window.location.hash);
|
||
if (!parsed) return;
|
||
const currentActive = activeSessionIdRef.current;
|
||
if (parsed.sessionId && parsed.sessionId !== currentActive) {
|
||
setActiveSessionId(parsed.sessionId);
|
||
setTimeout(() => {
|
||
navigateToAnchor(parsed, {
|
||
sessionId: parsed.sessionId,
|
||
loadDayForAnchor: loadDayForAnchorRef.current,
|
||
});
|
||
}, 300);
|
||
} else {
|
||
navigateToAnchor(parsed, {
|
||
sessionId: currentActive,
|
||
loadDayForAnchor: loadDayForAnchorRef.current,
|
||
});
|
||
}
|
||
};
|
||
|
||
window.addEventListener('hashchange', handleHashChange);
|
||
// 仅在初始页面加载时检查一次 URL hash
|
||
if (window.location.hash) {
|
||
handleHashChange();
|
||
}
|
||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||
}, []); // 仅在挂载时监听 hashchange,避免 activeSessionId 变更时因残留 hash 产生回弹
|
||
|
||
// 引用消息交互
|
||
const handleQuoteMessage = (message: ChatMessage) => {
|
||
let msgId = message.serverId;
|
||
if (!msgId && typeof message.id === 'string' && message.id.startsWith('msg-')) {
|
||
const parsedId = parseInt(message.id.slice(4), 10);
|
||
if (!isNaN(parsedId) && parsedId > 0) {
|
||
msgId = parsedId;
|
||
}
|
||
}
|
||
if (!msgId) {
|
||
msgId = Date.now();
|
||
}
|
||
const currentDay = (message as any).dayKey || new Date().toISOString().substring(0, 10);
|
||
setActiveQuotes((prev) => {
|
||
if (prev.some((q) => q.messageId === msgId)) return prev;
|
||
return [
|
||
...prev,
|
||
{
|
||
messageId: msgId!,
|
||
authorName: message.authorName || (message.role === 'user' ? '用户' : '项目助手'),
|
||
excerpt: message.content ? message.content.slice(0, 150) : '',
|
||
dayKey: currentDay,
|
||
},
|
||
];
|
||
});
|
||
};
|
||
|
||
const handleRemoveQuote = (index: number) => {
|
||
setActiveQuotes((prev) => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态)。
|
||
// 孤儿 streaming_status(任务已终态但气泡未愈合)不得继续锁住发送。
|
||
const isStreamingActive = useMemo(
|
||
() =>
|
||
resolveChatComposerStreaming(currentSessionMessages, {
|
||
turnLive: turnRuntime.isThinking,
|
||
// Retained activeTaskRunId alone must not lock send after the run is
|
||
// terminal; orphan streaming_status + leftover run id was blocking UI-05+.
|
||
taskRunLive: taskRunStream.isThinking,
|
||
}),
|
||
[currentSessionMessages, turnRuntime.isThinking, taskRunStream.isThinking],
|
||
);
|
||
|
||
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
|
||
const handleCardAction = useCardActions({
|
||
messages,
|
||
currentProjectId,
|
||
activeSessionId,
|
||
activeDocId,
|
||
currentUser,
|
||
deliverables,
|
||
setMessages,
|
||
setDeliverables,
|
||
setDocCache,
|
||
setActiveDocId,
|
||
setActiveCategory,
|
||
setHighlightedBlockId,
|
||
});
|
||
|
||
// 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用)
|
||
const { runStreamAttempt, runTaskAttempt, handleErrorAction } = useTaskRunAttempts({
|
||
activeSessionId,
|
||
activeDocId,
|
||
currentProjectId,
|
||
currentUser,
|
||
currentDocRole,
|
||
sessions,
|
||
messages,
|
||
deliverables,
|
||
visibleDeliverables,
|
||
currentProjectDeliverables,
|
||
activeWorkflowKey: currentWfState.activeKey || undefined,
|
||
workflowScopeMode,
|
||
deliverablesRef,
|
||
activeDocIdRef,
|
||
editorHydrationGateRef,
|
||
turnRuntime,
|
||
taskRunStream,
|
||
chatWindow,
|
||
setMessages,
|
||
setDeliverables,
|
||
setDocCache,
|
||
setActiveDocId,
|
||
setActiveCategory,
|
||
applyControlledInvalidationPlan,
|
||
});
|
||
|
||
// 用户主动停止:中止当前 session 活跃 turn,并结算仍挂在 inflight 的孤儿气泡。
|
||
const handleStopStreaming = () => {
|
||
turnRuntime.abortActiveSessionTurns();
|
||
taskRunStream.abortSession(activeSessionId);
|
||
const runId = taskRunStream.activeTaskRunId;
|
||
if (runId) {
|
||
void cancelTaskRun(runId).catch(() => undefined);
|
||
taskRunStream.setRunId(activeSessionId, null);
|
||
}
|
||
setMessages((prev) => cancelInflightAssistantMessages(prev, activeSessionId));
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
turnRuntime.abortAllSessions();
|
||
taskRunStream.abortAllSessions();
|
||
void authClient.logout().finally(() => {
|
||
setSession(null);
|
||
setCapabilities([]);
|
||
setSessionValidated(false);
|
||
setProjects([]);
|
||
resetWorkbenchStorage();
|
||
setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES));
|
||
setSessions(loadSessions());
|
||
setMessages([]);
|
||
setDocCache({});
|
||
setActiveDocId('');
|
||
});
|
||
};
|
||
|
||
// 发送消息与对话驱动双轨逻辑
|
||
// 编制人接受变更提议(经 HTTP decide)
|
||
const handleAcceptProposal = () => {
|
||
if (!pendingProposal?.proposalId) return;
|
||
const proposalId = pendingProposal.proposalId;
|
||
void (async () => {
|
||
try {
|
||
setProposalError(null);
|
||
await decideModificationProposal({
|
||
proposal_id: proposalId,
|
||
approve: true,
|
||
reason: '编制人确认采纳变更提案',
|
||
});
|
||
setPendingProposal(null);
|
||
const children = docAST.rootBlock.children || [];
|
||
const pBlock = children.find((c) => c.type === 'paragraph');
|
||
if (pBlock) {
|
||
pBlock.content +=
|
||
'\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
|
||
pBlock.metadata = {
|
||
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
|
||
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||
};
|
||
setDocAST({ ...docAST });
|
||
}
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-p-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '✅ 协同变更已生效',
|
||
content: `变更提议 ${proposalId} 已批准并落盘至《${docAST.title}》,受控文档已同步更新。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
setProposalError(message);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-p-err-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '⚠️ 批准提案失败',
|
||
content: `批准变更提案失败:${message}`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const handleRejectProposal = () => {
|
||
if (!pendingProposal?.proposalId) {
|
||
setPendingProposal(null);
|
||
return;
|
||
}
|
||
const proposalId = pendingProposal.proposalId;
|
||
void (async () => {
|
||
try {
|
||
setProposalError(null);
|
||
await decideModificationProposal({
|
||
proposal_id: proposalId,
|
||
approve: false,
|
||
reason: '编制人拒绝本次变更',
|
||
});
|
||
setPendingProposal(null);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-p-rej-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🚫 变更提案已拒绝',
|
||
content: `已拒绝变更提议 ${proposalId},受控文档保持原状。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
setProposalError(message);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-p-rej-err-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '⚠️ 拒绝提案失败',
|
||
content: `拒绝变更提案失败:${message}`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const {
|
||
handleSaveBlockEdit,
|
||
handleInsertBlock,
|
||
handleMoveBlock,
|
||
handleDeleteBlock,
|
||
handleAddComment,
|
||
} = useBlockEditing({
|
||
docAST,
|
||
setDocAST,
|
||
activeDocId,
|
||
deliverables,
|
||
currentUser,
|
||
currentDocRole,
|
||
editorSession,
|
||
commentingBlock,
|
||
editorHydrationGateRef,
|
||
setDocCache,
|
||
setDocRevConflict,
|
||
setDocRevOverlay,
|
||
setDocRevRefreshKey,
|
||
closeBlockEditor,
|
||
});
|
||
|
||
const {
|
||
handleClaim,
|
||
handleSubmitReview,
|
||
handlePassReview,
|
||
handleRejectReview,
|
||
handleApprove,
|
||
handleRejectApprove,
|
||
handleRequestReopen,
|
||
} = useDocumentLifecycleActions({
|
||
docAST,
|
||
setDocAST,
|
||
activeDocId,
|
||
currentUser,
|
||
activeSessionId,
|
||
projectId: currentProjectId,
|
||
setDeliverables,
|
||
setMessages,
|
||
});
|
||
|
||
const handleSendMessage = useSendMessage({
|
||
currentProjectId,
|
||
currentUser,
|
||
activeSessionId,
|
||
activeDocId,
|
||
activeDocIdRef,
|
||
messages,
|
||
docAST,
|
||
docCache,
|
||
currentDocRole,
|
||
deliverables,
|
||
visibleDeliverables,
|
||
currentProjectDeliverables,
|
||
activeWorkflowKey: currentWfState.activeKey || undefined,
|
||
workflowScopeMode,
|
||
sessions,
|
||
setDeliverables,
|
||
setDocCache,
|
||
setActiveDocId,
|
||
setActiveCategory,
|
||
setActiveQuotes,
|
||
setMessages,
|
||
setProposalError,
|
||
setPendingProposal,
|
||
taskRunStream,
|
||
handleCardAction,
|
||
runTaskAttempt,
|
||
runStreamAttempt,
|
||
handlePassReview,
|
||
handleApprove,
|
||
});
|
||
|
||
// 导出 DOCX (Word 渲染器)
|
||
const handleExportDocx = async () => {
|
||
try {
|
||
const { exportDocumentToDocx } = await import('./engine/docxRenderer');
|
||
await exportDocumentToDocx(docAST);
|
||
} catch (err) {
|
||
console.error('导出 DOCX 失败:', err);
|
||
}
|
||
};
|
||
|
||
const handleExportReplyDocx = async (message: ChatMessage) => {
|
||
try {
|
||
const { downloadDocxReply } = await import('./features/replyExport/downloadDocx');
|
||
await downloadDocxReply(message.content || '', undefined, {
|
||
createdAt: message.createdAt || message.timestamp,
|
||
});
|
||
} catch (err: any) {
|
||
console.error('导出单条回复 DOCX 失败:', err);
|
||
}
|
||
};
|
||
|
||
const handleExportXlsx = async () => {
|
||
try {
|
||
const { exportDocumentToXlsx } = await import('./engine/xlsxRenderer');
|
||
await exportDocumentToXlsx(docAST);
|
||
} catch (err) {
|
||
console.error('导出 XLSX 失败:', err);
|
||
}
|
||
};
|
||
|
||
// 导出 Markdown 原文
|
||
// T20-F8: blob URL 统一 try/finally revoke,防止异常路径泄漏。
|
||
const handleExportMarkdown = () => {
|
||
const md = blockASTToMarkdown(docAST);
|
||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
try {
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${docAST.title}_${docAST.version}_受控交付物.md`;
|
||
a.click();
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
};
|
||
|
||
const handleRoleChangeForDoc = async (docId: string, role: DocPermissionRole) => {
|
||
if (role === 'viewer') return;
|
||
const item = deliverables.find((doc) => doc.docId === docId);
|
||
if (!item?.projectId || !item.taskKey) {
|
||
throw new Error('该文档没有服务端任务,无法改派职责');
|
||
}
|
||
const uid = currentUser.id;
|
||
let author = role === 'author' ? uid : item.authorId;
|
||
let reviewer = role === 'reviewer' ? uid : item.reviewerId;
|
||
let approver = role === 'approver' ? uid : item.approverId;
|
||
if (item.dutyPolicy === 'author_only') {
|
||
const owner = author || uid;
|
||
author = owner;
|
||
reviewer = owner;
|
||
approver = owner;
|
||
}
|
||
await reassignDocTaskDuties(item.projectId, item.taskKey, {
|
||
author_user_id: author,
|
||
reviewer_user_id: reviewer,
|
||
approver_user_id: approver,
|
||
duty_policy: item.dutyPolicy,
|
||
});
|
||
setWorkflowDocsEpoch((epoch) => epoch + 1);
|
||
};
|
||
|
||
// 未登录拦截门禁:展示现代化医疗合规登录与注册页面
|
||
const handleCreateProjectConfirm = useCreateProjectConfirm({
|
||
currentUser,
|
||
createProjectInFlightRef,
|
||
createProjectBootstrapIdRef,
|
||
setCreateProjectError,
|
||
setCreateProjectSubmitting,
|
||
setCreateProjectSubmitPhase,
|
||
setCreateProjectModalOpen,
|
||
setProjects,
|
||
setSessions,
|
||
setMessages,
|
||
setDeliverables,
|
||
setDocCache,
|
||
setActiveSessionId,
|
||
setActiveDocId,
|
||
setActiveCategory,
|
||
});
|
||
|
||
const {
|
||
handleAddProjectMember,
|
||
handleUpdateProjectMemberRole,
|
||
handleRemoveProjectMember,
|
||
handleRestoreProjectMember,
|
||
} = useProjectMemberActions({
|
||
currentProject,
|
||
syncProjectMembersFromServer,
|
||
setWorkflowDocsEpoch,
|
||
setSessions,
|
||
setMessages,
|
||
setProjectMembersError,
|
||
});
|
||
|
||
if (!session) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||
<MaintenanceBanner />
|
||
<div style={{ flex: 1 }}>
|
||
<LoginView
|
||
onLoginSuccess={(newSession) => {
|
||
const switched = adoptWorkbenchUser(newSession.user.id);
|
||
if (switched) {
|
||
setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES));
|
||
setSessions(loadSessions());
|
||
setMessages([]);
|
||
setDocCache({});
|
||
setProjects([]);
|
||
setActiveDocId('');
|
||
setActiveSessionId(loadActiveSessionId(''));
|
||
}
|
||
setSession(newSession);
|
||
setSessionValidated(true);
|
||
setCapabilities(newSession.capabilities ?? []);
|
||
if (newSession.env) setSystemEnv(newSession.env);
|
||
const match = allUsers.find((u) => u.id === newSession.user.id);
|
||
setCurrentUser(mergeSessionUser(newSession.user, match));
|
||
}}
|
||
systemConfig={systemAuthConfig}
|
||
env={systemEnv}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell
|
||
if (view === 'admin') {
|
||
return (
|
||
<OnDemand
|
||
label="管理台"
|
||
load={loadAdminConsoleView}
|
||
componentProps={{
|
||
currentUser,
|
||
env: systemEnv,
|
||
onExit: () => setView('workbench'),
|
||
onUsersChanged: async () => {
|
||
const users = await authClient.getAdminUsers();
|
||
setAllUsers(users);
|
||
},
|
||
onRolesChanged: (newRoles: RoleDefinition[]) => setRoles(newRoles),
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<WorkbenchView
|
||
visibleSessions={visibleSessions}
|
||
activeSessionId={activeSessionId}
|
||
sessions={sessions}
|
||
deliverables={deliverables}
|
||
currentProjectId={currentProjectId}
|
||
setActiveSessionId={setActiveSessionId}
|
||
setActiveDocId={setActiveDocId}
|
||
setActiveCategory={setActiveCategory}
|
||
setCreateProjectModalOpen={setCreateProjectModalOpen}
|
||
canCreateProject={canCreateProject}
|
||
currentUser={currentUser}
|
||
currentDocRole={currentDocRole}
|
||
setRightsModalOpen={setRightsModalOpen}
|
||
setUserSettingsOpen={setUserSettingsOpen}
|
||
setView={setView}
|
||
setProjectMembersModalOpen={setProjectMembersModalOpen}
|
||
currentWfState={currentWfState}
|
||
workflowGovernanceEnabled={workflowGovernanceEnabled}
|
||
setProjectSettingsOpen={setProjectSettingsOpen}
|
||
setProjectWorkflowsState={setProjectWorkflowsState}
|
||
workflowBanner={workflowBanner}
|
||
chatContent={
|
||
<ChatColumn
|
||
activeSessionId={activeSessionId}
|
||
categoryStats={categoryStats}
|
||
chatWindow={chatWindow}
|
||
isCalendarOpen={isCalendarOpen}
|
||
setIsCalendarOpen={setIsCalendarOpen}
|
||
isSearchOpen={isSearchOpen}
|
||
setIsSearchOpen={setIsSearchOpen}
|
||
isDigestOpen={isDigestOpen}
|
||
setIsDigestOpen={setIsDigestOpen}
|
||
isExportOpen={isExportOpen}
|
||
setIsExportOpen={setIsExportOpen}
|
||
unreadTotal={unreadTotal}
|
||
dateStats={dateStats}
|
||
unreadMarks={unreadMarks}
|
||
unreadMessageIds={unreadMessageIds}
|
||
isAllReadCompleted={isAllReadCompleted}
|
||
consumeNextUnread={consumeNextUnread}
|
||
handleSelectDoc={handleSelectDoc}
|
||
handleCardAction={handleCardAction}
|
||
handleErrorAction={handleErrorAction}
|
||
handleQuoteMessage={handleQuoteMessage}
|
||
handleSaveMessageToPrivate={handleSaveMessageToPrivate}
|
||
handleExportReplyDocx={handleExportReplyDocx}
|
||
pendingProposal={pendingProposal}
|
||
proposalError={proposalError}
|
||
handleAcceptProposal={handleAcceptProposal}
|
||
handleRejectProposal={handleRejectProposal}
|
||
handleSendMessage={handleSendMessage}
|
||
isThinking={isThinking}
|
||
isStreamingActive={isStreamingActive}
|
||
handleStopStreaming={handleStopStreaming}
|
||
activeQuotes={activeQuotes}
|
||
handleRemoveQuote={handleRemoveQuote}
|
||
isCurrentProjectExited={isCurrentProjectExited}
|
||
chatBlockedNoProject={chatBlockedNoProject}
|
||
/>
|
||
}
|
||
docContent={
|
||
<DocumentPane
|
||
isCurrentProjectExited={isCurrentProjectExited}
|
||
activeDocId={activeDocId}
|
||
deliverables={deliverables}
|
||
visibleDeliverables={visibleDeliverables}
|
||
activeCategory={activeCategory}
|
||
setActiveCategory={setActiveCategory}
|
||
handleSelectDoc={handleSelectDoc}
|
||
deckDrawerOpen={deckDrawerOpen}
|
||
setDeckDrawerOpen={setDeckDrawerOpen}
|
||
handleExportDocx={handleExportDocx}
|
||
handleExportXlsx={handleExportXlsx}
|
||
handleExportMarkdown={handleExportMarkdown}
|
||
docRevOverlayMode={docRevOverlayMode}
|
||
setDocRevOverlayMode={setDocRevOverlayMode}
|
||
setDocRevOverlay={setDocRevOverlay}
|
||
docRevRefreshKey={docRevRefreshKey}
|
||
docRevConflict={docRevConflict}
|
||
docRevOverlay={docRevOverlay}
|
||
currentUser={currentUser}
|
||
handleShareToProject={handleShareToProject}
|
||
handleRevokeShare={handleRevokeShare}
|
||
handleDeleteDoc={handleDeleteDoc}
|
||
setTargetDocForVisibility={setTargetDocForVisibility}
|
||
setDocVisibilityModalOpen={setDocVisibilityModalOpen}
|
||
docLoadErrors={docLoadErrors}
|
||
docLoadErrorInfo={docLoadErrorInfo}
|
||
retryControlledDocLoad={retryControlledDocLoad}
|
||
docAST={docAST}
|
||
currentDocRole={currentDocRole}
|
||
isActiveProjectMember={isActiveProjectMember}
|
||
openBlockEditor={openBlockEditor}
|
||
handleInsertBlock={handleInsertBlock}
|
||
handleMoveBlock={handleMoveBlock}
|
||
handleDeleteBlock={handleDeleteBlock}
|
||
setCommentingBlock={setCommentingBlock}
|
||
handleSendMessage={handleSendMessage}
|
||
highlightedBlockId={highlightedBlockId}
|
||
canOverrideVisibility={canOverrideVisibility}
|
||
handleClaim={handleClaim}
|
||
handleSubmitReview={handleSubmitReview}
|
||
handlePassReview={handlePassReview}
|
||
handleRejectReview={handleRejectReview}
|
||
handleApprove={handleApprove}
|
||
handleRejectApprove={handleRejectApprove}
|
||
handleRequestReopen={handleRequestReopen}
|
||
editorSession={editorSession}
|
||
handleSaveBlockEdit={handleSaveBlockEdit}
|
||
closeBlockEditor={closeBlockEditor}
|
||
commentingBlock={commentingBlock}
|
||
handleAddComment={handleAddComment}
|
||
/>
|
||
}
|
||
rightsModalOpen={rightsModalOpen}
|
||
visibleDeliverables={visibleDeliverables}
|
||
activeDocId={activeDocId}
|
||
canAssignLead={canAssignLead}
|
||
handleSelectDoc={handleSelectDoc}
|
||
handleRoleChangeForDoc={handleRoleChangeForDoc}
|
||
session={session}
|
||
setSession={setSession}
|
||
handleLogout={handleLogout}
|
||
userSettingsOpen={userSettingsOpen}
|
||
roles={roles}
|
||
setAllUsers={setAllUsers}
|
||
setCurrentUser={setCurrentUser}
|
||
docVisibilityModalOpen={docVisibilityModalOpen}
|
||
setDocVisibilityModalOpen={setDocVisibilityModalOpen}
|
||
targetDocForVisibility={targetDocForVisibility}
|
||
handleSaveDocVisibilityRule={handleSaveDocVisibilityRule}
|
||
createProjectModalOpen={createProjectModalOpen}
|
||
createProjectSubmitting={createProjectSubmitting}
|
||
setCreateProjectError={setCreateProjectError}
|
||
handleCreateProjectConfirm={handleCreateProjectConfirm}
|
||
createProjectError={createProjectError}
|
||
createProjectSubmitPhase={createProjectSubmitPhase}
|
||
allUsers={allUsers}
|
||
projectMembersModalOpen={projectMembersModalOpen}
|
||
currentProject={currentProject}
|
||
canManageProjectMembers={canManageProjectMembers}
|
||
projectMembersError={projectMembersError}
|
||
handleAddProjectMember={handleAddProjectMember}
|
||
handleUpdateProjectMemberRole={handleUpdateProjectMemberRole}
|
||
handleRemoveProjectMember={handleRemoveProjectMember}
|
||
handleRestoreProjectMember={handleRestoreProjectMember}
|
||
projectSettingsOpen={projectSettingsOpen}
|
||
currentActiveSession={currentActiveSession}
|
||
/>
|
||
);
|
||
|
||
}
|