Files
Agent-System/web/src/App.tsx
T
weichen db038aa7a7 fix(web): stop phantom demo-fallback requests in seeds-off mode
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.
2026-09-27 03:09:37 +00:00

1759 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
/>
);
}