Files
Agent-System/web/src/App.tsx
T
weichenandCursor c22faecfcb refactor(web): move document lifecycle actions into a hook
Keep claim, review, and approval on one server record so the open document and the sidebar stay in the same state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 11:40:03 +00:00

4085 lines
158 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 { AppShell } from './components/layout/AppShell';
import { MessageStream } from './components/chat/MessageStream';
import { InputArea } from './components/chat/InputArea';
import { SlimTabBar } from './components/doc/SlimTabBar';
import { DeckDrawer } from './components/doc/DeckDrawer';
import { BlockRenderer } from './components/doc/BlockRenderer';
import { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolbar';
import { CommentSidePanel } from './components/doc/CommentSidePanel';
import { DocGovActionBar } from './components/doc/DocGovActionBar';
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
import {
createFreeformDoc,
saveMessageToPrivate,
fetchFreeformDoc,
shareFreeformDoc,
unshareFreeformDoc,
deleteFreeformDoc,
} from './api/freeformDoc';
import { registerUploadAttachments } from './lib/uploadFreeformRegister';
import { upsertChatSession } from './api/chatSessions';
import { listProjectWorkflowDocuments } from './api/projectDocuments';
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
import {
documentRevisionInactiveHint,
revisionToolbarMountMode,
} from './features/documentRevision/revisionToolbarMount';
import { createProject, fetchProjectLineage, getProject } from './api/projectWorkflow';
import {
addProjectMember,
listProjectMembers,
removeProjectMember,
updateProjectMember,
} from './api/projectMembers';
import {
buildManagedMemberUpsertPayload,
ensureCreatedProjectWithMembers,
} from './lib/createProjectMembersPersist';
import { afterProjectMemberPersisted } from './lib/afterProjectMemberPersisted';
import {
beginCreateProjectSubmit,
endCreateProjectSubmit,
resolveCreateProjectSubmitPhase,
shouldCloneMedicalTemplates,
type CreateProjectSubmitPhase,
} from './lib/createProjectSubmitGuard';
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync';
import { resolveCurrentDocRole } from './lib/currentDocRole';
import { holdsDocDuty } from './lib/docGovActionVisibility';
import {
proposeModification,
decideModificationProposal,
} from './api/docgov';
import { canAddBlockComment } from './lib/commentPolicy';
import {
pollUntilPostTaskServerDoc,
shouldSwitchTemplateSlotToServer,
} from './lib/postTaskDeliverableSync';
import {
isRemoteTaskTerminal,
nextAttemptOpts,
resolveReconnectRunId,
shouldSubscribeExistingRun,
} from './lib/taskRunReconnect';
import {
GOVERNANCE_BLOCKED_HINT,
isBlockedGovernanceStatus,
} from './lib/blockedGovernanceCopy';
import {
buildControlledLoadingSkeleton,
canonicalDocIdForTask,
controlledHydrateFingerprint,
hydrateControlledDocumentContent,
shouldLoadControlledContent,
} from './lib/controlledDocHydrate';
import {
controlledHydrateCandidateIds,
dropRecordKeys,
planControlledContentInvalidation,
type ControlledInvalidationPlan,
} from './lib/controlledContentInvalidation';
import {
createEditorHydrationGate,
} from './lib/editorHydrationGate';
import {
decideActiveDocSwitch,
type BlockEditorSession,
} from './lib/blockSavePolicy';
import { saveControlledBlockEdit } from './lib/saveControlledBlockEdit';
import { DocumentLineage } from './types/projectWorkflow';
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
import { deriveReplyTitle } from './features/replyExport/title';
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
import { downloadMarkdownReply } from './features/replyExport/download';
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
import { UserSettingsModal } from './components/user/UserSettingsModal';
import { OnDemand } from './components/lazy/OnDemand';
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal';
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
import { fetchProjectSettings } from './api/projectSettings';
import { ShieldAlert } from 'lucide-react';
import { LoginView } from './components/auth/LoginView';
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
import { authClient } from './api/auth';
import { streamChat, mapErrorInfo } from './api/chatStream';
import { streamTask, buildTaskRequest, buildResumeRequest } from './api/taskStream';
import type { StreamEvent } from './api/chatStream';
import { shouldAutoRetryChat, shouldAutoRetryTask, autoRetryDelayMs, fallbackErrorClass } from './api/chatRetry';
import { applyPhaseEvent } from './engine/phaseEventMapper';
import { LoginResult, SystemAuthConfig } from './types/auth';
import { markdownToBlockAST } from './engine/markdownToBlock';
import { blockASTToMarkdown } from './engine/blockToMarkdown';
import { recomputeNumbering } from './engine/autoNumber';
import { DocumentAST, DocumentBlock, BlockComment } from './types/block';
import {
ProjectSession,
ChatMessage,
ChatErrorInfo,
DeliverableItem,
DeliverableCategory,
InteractiveCardData,
PhaseEvent,
TaskRunContext,
TaskRunMaterial,
ProjectInfo,
ProjectMember,
ProjectMemberRole,
} 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 {
findMatchingBlockForReview,
createRejectConfirmationCard,
isAffirmativeResponse,
findLastActiveCard,
decrementActiveCardsTtl,
applyConfirmRejectToDoc,
applyUndoRejectToDoc,
resolveCardInMessages,
cancelCardInMessages,
} from './engine/cardActionEngine';
import {
applyPlanViewToCard,
cardFromPlanView,
isPlanConfirmCard,
markPlanCard,
reconcileConflictCard,
} from './engine/planCard';
import {
fetchActiveIntentPlan,
fetchIntentPlan,
submitIntentPlanDecision,
} from './api/intentPlans';
import {
ALL_INITIAL_DELIVERABLES,
ALL_MOCK_DOCUMENTS,
MOCK_USERS,
} from '@demo-seeds';
import {
createProjectTemplateDeliverables,
getTemplateDefinitionByTaskKey,
buildTemplateASTForProject,
} from './engine/templateFactory';
import { resolveTaskTemplateForMessage } from './engine/templateMatch';
import {
loadSessions,
loadActiveSessionId,
loadDeliverables,
loadUsers,
purgeDemoSeedsFromLocalStorage,
resolveActiveSessionForUser,
loadProjects,
filterProjectSessionsForUser,
getProjectMembership,
buildPrivateSessionId,
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions';
import { useServerDocumentSync } from './hooks/useServerDocumentSync';
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
import { useTurnRuntime } from './hooks/useTurnRuntime';
import { useTaskRunStream } from './hooks/useTaskRunStream';
import { fetchChatTurnStatus } from './api/chatTurn';
import { createClientTurnId } from './lib/createClientTurnId';
import { mergeSessionUser } from './lib/mergeSessionUser';
import { RequestEpoch, shareInflight } from './data/initFanout';
import {
resolveSessionProjectId,
} from './lib/sessionAdapter';
import { SendMessageResult } from './types/sendMessage';
import { CategoryFilterBar } from './components/history/CategoryFilterBar';
import { HistoryCalendar } from './components/history/HistoryCalendar';
import { ChatSearchModal } from './components/history/ChatSearchModal';
import { DigestTimelineModal } from './components/history/DigestTimelineModal';
import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator';
import { useUnreadMarks } from './hooks/useUnreadMarks';
import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench';
import { saveFile } from './data/fileStore';
import { uploadProjectAsset } from './api/projectAssets';
import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns';
/**
* 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。
*/
async function readAttachmentsAsMaterials(
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
projectId?: number,
): Promise<TaskRunMaterial[]> {
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
const results: TaskRunMaterial[] = [];
for (const att of attachments) {
if (att.assetId && att.assetId > 0) {
results.push({ filename: att.name, asset_id: att.assetId });
continue;
}
if (!att.fileRaw) continue;
const extMatch = att.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
if (!TEXT_EXTS.has(ext)) {
if (!projectId || projectId <= 0) {
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
}
const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name);
results.push({ filename: uploaded.filename || att.name, asset_id: uploaded.asset_id });
continue;
}
try {
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
const b64 = btoa(unescape(encodeURIComponent(text)));
resolve(b64);
};
reader.onerror = () => reject(reader.error);
reader.readAsText(att.fileRaw as Blob, 'utf-8');
});
results.push({ filename: att.name, content });
} catch {
throw new Error(`读取文本附件 ${att.name} 失败`);
}
}
return results;
}
export {
ALL_INITIAL_DELIVERABLES,
ALL_MOCK_DOCUMENTS,
MOCK_USERS,
} from '@demo-seeds';
const loadFilePreviewPanel = () =>
import('./components/doc/preview/FilePreviewPanel').then((m) => ({ default: m.FilePreviewPanel }));
const loadAdminConsoleView = () =>
import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView }));
const loadChatExportModal = () =>
import('./components/history/ChatExportModal').then((m) => ({ default: m.ChatExportModal }));
const loadBlockEditorModal = () =>
import('./components/doc/BlockEditorModal').then((m) => ({ default: m.BlockEditorModal }));
export default function App() {
// Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*)
purgeDemoSeedsFromLocalStorage();
// 会话与用户状态 (支持 localStorage 刷新持久化)
const [sessions, setSessions] = useState<ProjectSession[]>(() => loadSessions());
const [activeSessionId, setActiveSessionId] = useState<string>(() => loadActiveSessionId('sess-001'));
// 全局角色库与人员名单状态 (支持动态增删改与持久化)
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 (默认为 1)
const currentProjectId = useMemo(() => {
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
const resolved = resolveSessionProjectId(activeSess);
return resolved > 0 ? resolved : 1;
}, [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]);
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 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,
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 (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 [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 = (
<>
{(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);
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);
});
}
}
}
};
// 一键将个人私密文档共享至项目协作参考库
const handleShareToProject = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await shareFreeformDoc(docId);
}
} catch (e) {
console.error('Share doc error:', e);
}
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', space: 'project_shared', sharedAt: nowStr } : d))
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'shared', space: 'project_shared', sharedAt: nowStr },
};
});
setActiveCategory('shared');
const targetDoc = deliverables.find((d) => d.docId === docId);
setMessages((prev) => [
...prev,
{
id: 'ast-share-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🌐 一键共享至项目参考库',
content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
// 取消共享 (恢复为个人私有草稿)
const handleRevokeShare = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await unshareFreeformDoc(docId);
}
} catch (e) {
console.error('Unshare doc error:', e);
}
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'private', space: 'my_private', sharedAt: undefined } : d))
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'private', space: 'my_private', sharedAt: undefined },
};
});
setActiveCategory('private');
const targetDoc = deliverables.find((d) => d.docId === docId);
setMessages((prev) => [
...prev,
{
id: 'ast-revoke-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🔒 撤回共享至个人私密',
content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
// 删除私密文档
const handleDeleteDoc = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await deleteFreeformDoc(docId);
}
} catch (e) {
console.error('Delete doc error:', e);
}
setDeliverables((prev) => prev.filter((d) => d.docId !== docId));
if (activeDocId === docId) {
const remaining = deliverables.filter((d) => d.docId !== docId);
const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0];
if (fallback) {
setActiveDocId(fallback.docId);
setActiveCategory(fallback.category);
}
}
};
// 单条智能体回复保存到“我的私密”文档
const handleSaveMessageToPrivate = async (message: ChatMessage) => {
try {
const serverId = message.serverId || 0;
let resData;
if (serverId > 0) {
try {
const resp = await saveMessageToPrivate(
serverId,
currentProjectId,
undefined,
message.contentSha256,
);
resData = resp.data;
} catch (serverErr: any) {
console.warn('服务端自由文档落库失败,降级为本地草稿保存:', serverErr);
}
}
const docId = resData?.document.doc_id || `doc-free-${Date.now()}`;
const title = resData?.document.title || deriveReplyTitle(message.content, message.timestamp);
const markdown = resData?.content_markdown || message.content;
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
const newDocItem: DeliverableItem = {
docId,
projectId: currentProjectId,
taskKey: 'freeform_private',
title,
stage: 'V0.1',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: nowStr,
summary: message.content.slice(0, 120),
governanceMode: 'freeform',
space: 'my_private',
};
const newAST = markdownToBlockAST(markdown, {
docId,
title,
taskKey: 'freeform_private',
version: `${resData?.document.current_version_id || 1}`,
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
setDeliverables((prev) => {
const exists = prev.some((d) => d.docId === docId);
if (exists) return prev;
return [newDocItem, ...prev];
});
setDocCache((prev) => ({
...prev,
[docId]: newAST,
}));
setActiveDocId(docId);
setActiveCategory('private');
setDeckDrawerOpen(false);
} catch (err: any) {
console.error('保存到我的私密失败:', err);
throw err;
}
};
useServerDocumentSync({
session,
sessionValidated,
currentProjectId,
currentUser,
workflowDocsEpoch,
setDeliverables,
setDocCache,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
applyControlledInvalidationPlan,
});
// 模态弹窗与侧栏
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 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 poll = async () => {
const records = await Promise.all(
recoverableRunIds.map((runId) => getTaskRun(runId).catch(() => null)),
);
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);
};
}, [recoverableRunKey]);
// 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);
// 会话切换时加载分类与日期统计
useEffect(() => {
if (!activeSessionId || sessionValidated !== true) return;
fetchChatCategories(activeSessionId)
.then((cats) => {
setCategoryStats(cats || []);
})
.catch((err) => {
console.warn('Failed to load category stats:', err);
});
fetchChatDates(activeSessionId)
.then((dates) => {
setDateStats(dates || []);
})
.catch((err) => {
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 停止态)
const isStreamingActive = useMemo(() => {
return currentSessionMessages.some(
(m) =>
m.role === 'assistant' &&
(m.status === 'pending' || m.status === 'streaming_status' || m.status === 'auto_retrying')
);
}, [currentSessionMessages]);
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
const handleCardAction = (cardId: string, actionId: string, payload?: any) => {
// 查找对应的卡片
let targetCard: InteractiveCardData | undefined;
for (const m of messages) {
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
targetCard = m.interactiveCard;
break;
}
}
if (isPlanConfirmCard(targetCard, payload)) {
const planId = String(payload?.plan_id || targetCard?.planId || '');
const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0);
const kind = (payload?.kind || (actionId === 'plan_cancel' ? 'cancel' : 'confirm')) as
| 'confirm'
| 'cancel';
const projectId = currentProjectId;
const sessionId = activeSessionId;
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId
? { ...m, interactiveCard: markPlanCard(m.interactiveCard, 'executing') }
: m
)
);
void (async () => {
const decisionNonce =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${cardId}:${actionId}:${expectedRevision}:${Date.now()}:${Math.random()}`;
const result = await submitIntentPlanDecision({
planId,
projectId,
sessionId,
expectedRevision,
decisionNonce,
decisionDigest: `${kind}:${expectedRevision}`,
kind,
});
setMessages((prev) =>
prev.map((m) => {
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m;
if (result.httpStatus === 409) {
return { ...m, interactiveCard: reconcileConflictCard(m.interactiveCard, result.plan) };
}
if (result.plan) {
return { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, result.plan) };
}
return m;
})
);
if (!result.plan && result.httpStatus >= 400) {
const latest = await fetchIntentPlan(planId, projectId, sessionId).catch(() => null);
if (latest) {
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId && m.interactiveCard
? { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, latest) }
: m
)
);
}
}
})();
return;
}
const targetDocId =
payload?.targetDocId || payload?.docId || targetCard?.targetDocId || activeDocId;
const reason =
payload?.reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。';
if (actionId === 'confirm_reject') {
// 1. 调用既有文档驳回逻辑 (将目标文档生命周期由 reviewing 回退为 drafting)
setDeliverables((prev) =>
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'drafting' } : d))
);
// 2. 扫描目标文档 AST,在匹配块上追加 type: 'issue' 批注
let targetBlockId: string | null = null;
let targetSectionName = '第3节';
setDocCache((prev) => {
let currentDoc = prev[targetDocId];
if (!currentDoc && ALL_MOCK_DOCUMENTS[targetDocId]) {
const meta = ALL_MOCK_DOCUMENTS[targetDocId];
currentDoc = markdownToBlockAST(meta.markdown || '', {
docId: meta.docId || targetDocId,
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,
});
}
if (!currentDoc) return prev;
const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc(
currentDoc,
cardId,
currentUser.name,
reason,
targetCard?.sectionTitle || payload?.targetSection
);
targetBlockId = matchingBlock.id;
if (matchingBlock.numbering) {
targetSectionName = `第${matchingBlock.numbering.replace('.', '')}节`;
}
return {
...prev,
[targetDocId]: updatedDoc,
};
});
// 3. 更新卡片状态为 resolved
const sectionLabel =
targetCard?.sectionTitle?.match(/第[0-9一二三四五六七八九]+节/)?.[0] || targetSectionName || '第3节';
const resolutionSummary = `已正式退回编制阶段,批注已挂载至${sectionLabel}`;
setMessages((prev) => {
const updatedMessages = resolveCardInMessages(
prev,
cardId,
resolutionSummary,
currentUser.name
);
return [
...updatedMessages,
{
id: 'sys-rej-' + Date.now(),
sessionId: activeSessionId,
role: 'system',
content: `⚠️ ${currentUser.name}(审核负责人)驳回了《${targetCard?.targetDocTitle || targetDocId}》,已退回编制阶段。驳回原因:${reason}`,
timestamp: new Date().toTimeString().substring(0, 5),
},
];
});
// 4. 切换当前右侧激活文档为目标文档并高亮匹配块
setActiveDocId(targetDocId);
const targetDeliverable = deliverables.find((d) => d.docId === targetDocId);
if (targetDeliverable) {
setActiveCategory(targetDeliverable.category);
}
if (targetBlockId) {
setHighlightedBlockId(targetBlockId);
setTimeout(() => {
setHighlightedBlockId(null);
}, 2500);
}
} else if (actionId === 'cancel') {
// 标记卡片为 cancelled 并追加说明
setMessages((prev) => [
...cancelCardInMessages(prev, cardId),
{
id: 'ast-cancel-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🚫 取消操作',
content: '已取消驳回操作,文档保持待审核状态。',
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} else if (actionId === 'undo') {
// 撤销驳回:恢复为待审核状态 (reviewing)
setDeliverables((prev) =>
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d))
);
// 移除注入的批注并恢复 reviewing 状态
setDocCache((prev) => {
const currentDoc = prev[targetDocId];
if (!currentDoc) return prev;
const updatedDoc = applyUndoRejectToDoc(currentDoc, cardId);
return {
...prev,
[targetDocId]: updatedDoc,
};
});
// 标记卡片为 cancelled 并追加说明
setMessages((prev) => [
...cancelCardInMessages(prev, cardId),
{
id: 'ast-undo-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '↩️ 撤回驳回',
content: '已在合法窗口期撤回驳回,文档已恢复为待审核状态。',
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} else if (actionId === 'confirm_final_approve') {
// 终审批准
setDeliverables((prev) =>
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approved' } : d))
);
setDocCache((prev) => {
const currentDoc = prev[targetDocId];
if (!currentDoc) return prev;
return {
...prev,
[targetDocId]: { ...currentDoc, lifecycleState: 'approved' as const },
};
});
setMessages((prev) => [
...resolveCardInMessages(
prev,
cardId,
'已终审批准生效',
currentUser.name
),
{
id: 'appr-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🏆 终审批准生效',
content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${targetCard?.targetDocTitle || targetDocId}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
timestamp: new Date().toTimeString().substring(0, 5),
recommendedCases: [
{
docId: targetDocId,
taskKey: targetCard?.targetDocId || 'research',
artifactTitle: targetCard?.targetDocTitle || '受控交付物',
sectionTitle: '终审已生效标杆案例',
similarityScore: 1.0,
keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'],
summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
},
],
},
]);
} else if (actionId === 'confirm_review_pass' || actionId === 'confirm_review') {
// 审核通过:流转至待终审状态 (approving)
setDeliverables((prev) =>
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approving' } : d))
);
setDocCache((prev) => {
const currentDoc = prev[targetDocId];
if (!currentDoc) return prev;
return {
...prev,
[targetDocId]: { ...currentDoc, lifecycleState: 'approving' as const },
};
});
setMessages((prev) => [
...resolveCardInMessages(
prev,
cardId,
'审核已通过,流转至终审阶段',
currentUser.name
),
{
id: 'rev-pass-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '✅ 审核通过确认',
content: `✅ ${currentUser.name}(审核人)已审核通过《${targetCard?.targetDocTitle || targetDocId}》!正文继续保持锁定,已流转至终审批准人进行最终批准。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} else if (actionId === 'confirm_submit_review' || actionId === 'submit_review') {
// 提交审核:流转至待审核状态 (reviewing)
setDeliverables((prev) =>
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d))
);
setDocCache((prev) => {
const currentDoc = prev[targetDocId];
if (!currentDoc) return prev;
return {
...prev,
[targetDocId]: { ...currentDoc, lifecycleState: 'reviewing' as const },
};
});
setMessages((prev) => [
...resolveCardInMessages(
prev,
cardId,
'已正式提交审核,正文锁定',
currentUser.name
),
{
id: 'sub-rev-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '📤 提交审核确认',
content: `📤 ${currentUser.name}(编制人)已正式提交《${targetCard?.targetDocTitle || targetDocId}》进入审核流程!正文已锁定,李主管(审核人)可在工作台或会话流中进行合规复核。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
}
};
// 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用)
const buildNetworkErrorInfo = (
httpStatus?: number,
opts?: { errorCode?: string; detail?: string },
): ChatErrorInfo => {
const code = opts?.errorCode || '';
const detail = opts?.detail || '';
if (code === 'DOC_BUSY' || code === 'document_busy') {
return {
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
code: 'DOC_BUSY',
class: 'fatal',
actions: [],
};
}
if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') {
return {
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
code,
class: 'transient',
actions: ['retry'],
};
}
if (code === 'DOC_DUTY_FORBIDDEN') {
return {
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
code,
class: 'fatal',
actions: ['retry'],
};
}
if (httpStatus === 401) {
return {
userMessage: '登录已失效,请重新登录后再试',
code: 'ERR_UNAUTHENTICATED',
class: 'fatal',
actions: ['retry'],
};
}
if (httpStatus === 403) {
if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') {
return {
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
code: code || 'ERR_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
code: code || 'ERR_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: '网络连接异常,请检查网络后重试',
code: 'NETWORK_ERROR',
class: fallbackErrorClass({ network: true }),
actions: ['retry'],
};
};
// 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。
// 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。
type StreamAttemptOpts = {
userMsgId?: string;
clientTurnId?: string;
quote?: QuotedMessage;
};
const runStreamAttempt = (
userText: string,
asstId: string,
attemptIndex: number,
opts?: StreamAttemptOpts
) => {
const quote = opts?.quote;
const controller = new AbortController();
const streamSessionId = activeSessionId;
if (opts?.clientTurnId) {
turnRuntime.registerTurn({
clientTurnId: opts.clientTurnId,
sessionId: streamSessionId,
userMsgId: opts.userMsgId || '',
asstId,
status: 'pending',
controller,
startedAt: Date.now(),
});
}
const currentProj = sessions.find((s) => s.sessionId === streamSessionId);
let accumulated = '';
let settled = false;
const updateAsst = (patch: Partial<ChatMessage>) => {
setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ...patch } : m)));
};
const scheduleRetry = (delayMs: number) => {
setTimeout(() => {
if (controller.signal.aborted) {
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
if (opts?.clientTurnId) {
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'cancelled');
}
return;
}
runStreamAttempt(userText, asstId, attemptIndex + 1, opts);
}, delayMs);
};
const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => {
if (opts?.clientTurnId) {
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status);
}
};
const failOrRetry = (info: ChatErrorInfo) => {
settled = true;
if (shouldAutoRetryChat(info, attemptIndex)) {
const delayMs = autoRetryDelayMs(attemptIndex);
updateAsst({
status: 'auto_retrying',
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
});
scheduleRetry(delayMs);
return;
}
updateAsst({
status: 'failed',
errorInfo: {
...info,
userMessage:
attemptIndex > 0
? `${info.userMessage}(请确认上一请求是否已在处理后再手动重试)`
: info.userMessage,
},
livePhaseLabel: undefined,
});
finishTurn('failed');
};
updateAsst({
status: 'pending',
livePhaseLabel: attemptIndex > 0 ? '正在手动重试…' : '助手正在处理…',
content: '',
errorInfo: undefined,
});
if (opts?.clientTurnId) {
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'pending');
}
const bindUserServerId = (serverId: number) => {
if (!opts?.userMsgId) return;
chatWindow.replaceMessageServerId(opts.userMsgId, serverId);
setMessages((prev) =>
prev.map((m) => (m.id === opts.userMsgId ? { ...m, serverId } : m))
);
};
streamChat(
{
session_id: streamSessionId,
user_id: currentUser.id,
project_id: currentProj?.projectId || currentProjectId || 1,
project_name: currentProj?.title || '肌骨超声AI辅助诊断系统',
device_type: currentProj?.deviceType || '肌骨超声',
stage: currentProj?.stage || 'V0.5',
user_name: currentUser.name,
user_role: currentDocRole,
message: userText,
client_turn_id: opts?.clientTurnId,
quote: quote,
},
{
onEvent: (ev) => {
if (controller.signal.aborted || settled) return;
switch (ev.type) {
case 'turn_ack': {
const userServerId = ev.turn?.user_message_id ?? ev.user_message_id;
if (typeof userServerId === 'number' && userServerId > 0) {
bindUserServerId(userServerId);
}
break;
}
case 'phase':
case 'status': {
const label = ev.phase?.message || ev.status?.message;
updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' });
if (opts?.clientTurnId) {
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
}
break;
}
case 'delta': {
accumulated += ev.delta || '';
updateAsst({ status: 'streaming_status', content: accumulated });
if (opts?.clientTurnId) {
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
}
break;
}
case 'error': {
failOrRetry(mapErrorInfo(ev));
break;
}
case 'done': {
settled = true;
const userServerId = ev.done?.user_message_id ?? ev.user_message_id;
const serverMsgId = ev.done?.message_id ?? ev.message_id ?? (ev as { messageId?: number }).messageId;
if (typeof userServerId === 'number' && userServerId > 0) {
bindUserServerId(userServerId);
}
if (typeof serverMsgId === 'number' && serverMsgId > 0) {
chatWindow.replaceMessageServerId(asstId, serverMsgId);
}
const exportCap = ev.export_capability || ev.done?.export_capability;
updateAsst({
status: 'completed',
livePhaseLabel: undefined,
serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined,
exportCapability: exportCap,
});
finishTurn('completed');
break;
}
default:
break;
}
},
},
controller.signal,
opts?.clientTurnId ? { idempotencyKey: opts.clientTurnId } : undefined
).catch(async (_err) => {
if (settled) return;
if (controller.signal.aborted) {
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
finishTurn('cancelled');
return;
}
if (opts?.clientTurnId) {
try {
const turnStatus = await fetchChatTurnStatus(streamSessionId, opts.clientTurnId);
if (turnStatus?.status === 'completed' && turnStatus.assistantMessageId) {
settled = true;
if (turnStatus.userMessageId) bindUserServerId(turnStatus.userMessageId);
chatWindow.replaceMessageServerId(asstId, turnStatus.assistantMessageId);
updateAsst({
status: 'completed',
livePhaseLabel: undefined,
serverId: turnStatus.assistantMessageId,
});
finishTurn('completed');
return;
}
if (turnStatus?.status === 'processing') {
updateAsst({
status: 'streaming_status',
livePhaseLabel: '连接中断,服务端仍在处理…',
});
if (opts?.clientTurnId) {
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
}
return;
}
} catch {
// fall through to structured failure
}
}
const httpStatus =
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
? Number((_err as { httpStatus?: number }).httpStatus)
: undefined;
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
failOrRetry(
buildNetworkErrorInfo(httpStatus, {
errorCode: errMeta.errorCode,
detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '),
}),
);
});
};
// P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区
const runTaskAttempt = (
task: TaskRunContext,
asstId: string,
mode: 'run' | 'resume' | 'restart',
attemptIndex: number,
opts?: { phaseEvents?: PhaseEvent[]; workDir?: string; reconnectRunId?: string }
) => {
const sessionId = task.sessionId || activeSessionId;
let retainedRunId = resolveReconnectRunId({
attemptIndex,
optsReconnectRunId: opts?.reconnectRunId,
});
taskRunStream.abortSession(sessionId);
// 重连保留显式 runId;禁止无条件 setRunId(null) 导致自动重试丢失
if (retainedRunId) {
taskRunStream.setRunId(sessionId, retainedRunId);
} else {
taskRunStream.setRunId(sessionId, null);
}
const controller = new AbortController();
taskRunStream.setController(sessionId, controller);
let accumulated = '';
let settled = false;
const patchAsst = (patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => {
setMessages((prev) =>
prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m))
);
};
const scheduleRetry = (delayMs: number) => {
setTimeout(() => {
if (controller.signal.aborted) {
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
taskRunStream.setThinking(sessionId, false);
return;
}
runTaskAttempt(task, asstId, mode, attemptIndex + 1, nextAttemptOpts(opts, retainedRunId));
}, delayMs);
};
const failOrRetry = (info: ChatErrorInfo) => {
settled = true;
if (shouldAutoRetryTask(info, attemptIndex)) {
const delayMs = autoRetryDelayMs(attemptIndex);
patchAsst({
status: 'auto_retrying',
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
});
scheduleRetry(delayMs);
return;
}
patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined });
taskRunStream.setThinking(sessionId, false);
};
const syncControlledDocsAfterTask = async (taskKey: string, preferredDocId?: string | null) => {
if (!task.projectId || task.projectId <= 0 || !taskKey) return;
const { merged, serverDocId } = await pollUntilPostTaskServerDoc({
listDocs: () => listProjectWorkflowDocuments(task.projectId!).catch(() => []),
getDeliverables: () => deliverablesRef.current,
merge: (prev, docs) =>
mergeWorkflowDocumentsFromServer(prev, docs, task.projectId!, currentUser),
projectId: task.projectId,
taskKey,
preferredDocId,
maxAttempts: 10,
delayMs: 800,
onMerged: (next) => {
deliverablesRef.current = next;
setDeliverables(next);
applyControlledInvalidationPlan(
planControlledContentInvalidation({
reason: 'post_task',
candidateIds: controlledHydrateCandidateIds(next, task.projectId),
activeDocId: activeDocIdRef.current,
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
}),
);
},
});
if (
serverDocId &&
shouldSwitchTemplateSlotToServer({
activeDocId,
deliverables: merged,
projectId: task.projectId,
taskKey,
serverDocId,
})
) {
setActiveDocId(serverDocId);
setActiveCategory('workflow');
}
};
// 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条
patchAsst({
status: 'pending',
livePhaseLabel:
mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…',
errorInfo: undefined,
...(mode === 'resume' || attemptIndex > 0
? {}
: { content: '', phaseEvents: [], taskWorkspaceId: undefined }),
});
taskRunStream.setThinking(sessionId, true);
const onEvent = (ev: StreamEvent) => {
if (controller.signal.aborted || settled) return;
switch (ev.type) {
case 'phase': {
patchAsst((m) => ({
status: 'streaming_status',
livePhaseLabel: ev.phase?.message || m.livePhaseLabel,
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
}));
break;
}
case 'status': {
if (ev.status?.message) {
patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message });
}
break;
}
case 'delta': {
accumulated += ev.delta || '';
patchAsst({ status: 'streaming_status', content: accumulated });
break;
}
case 'deliverable': {
const title = ev.deliverable?.title;
if (title) {
accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`;
patchAsst({ status: 'streaming_status', content: accumulated });
}
break;
}
case 'governance': {
const governance = ev.governance;
const docId = governance?.doc_id;
if (docId) {
const leaseReleased = governance.action === 'lease_released';
const editLease = leaseReleased
? undefined
: { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined };
setDeliverables((prev) =>
prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)),
);
setDocCache((prev) => {
const ast = prev[docId];
return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev;
});
}
if (governance?.notification_msg) {
patchAsst({
status: 'streaming_status',
livePhaseLabel: governance.notification_msg,
});
}
break;
}
case 'error': {
failOrRetry(mapErrorInfo(ev));
break;
}
case 'done': {
settled = true;
const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId;
taskRunStream.setRunId(sessionId, null);
taskRunStream.setThinking(sessionId, false);
void (async () => {
let nextStatus: AssistantMessageStatus = 'completed';
let preferredDocId: string | null = null;
if (finishedRunId) {
const remote = await getTaskRun(finishedRunId).catch(() => null);
if (remote?.status) {
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant');
if (mapped) nextStatus = mapped;
}
}
patchAsst({
status: nextStatus,
livePhaseLabel: undefined,
taskWorkspaceId: ev.done?.workspace_id,
...(finishedRunId ? { taskRunId: finishedRunId } : {}),
});
// quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换
if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') {
await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId);
}
await chatWindow.refresh().catch(() => undefined);
})();
break;
}
default:
break;
}
};
const startObserver = async () => {
const body =
mode === 'resume' && opts?.workDir
? buildResumeRequest(task, opts.workDir)
: buildTaskRequest(task);
if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') {
// 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId
const existingRunId = resolveReconnectRunId({
attemptIndex,
optsReconnectRunId: retainedRunId || opts?.reconnectRunId,
});
if (attemptIndex > 0 && existingRunId) {
const remote = await getTaskRun(existingRunId).catch(() => null);
if (remote && shouldSubscribeExistingRun(remote.status)) {
retainedRunId = existingRunId;
taskRunStream.setRunId(sessionId, existingRunId);
patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' });
await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal);
return;
}
if (remote && isRemoteTaskTerminal(remote.status)) {
settled = true;
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed';
patchAsst({
status: mapped,
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskRunId: existingRunId,
});
taskRunStream.setThinking(sessionId, false);
if (mapped === 'completed' || mapped === 'pending_author_confirmation') {
await syncControlledDocsAfterTask(task.taskKey || 'intake');
}
return;
}
}
const created = await createTaskRun({
...body,
session_id: task.sessionId,
project_id: task.projectId,
user_id: task.userId,
client_turn_id: task.clientTurnId,
user_message: task.userMessage,
});
retainedRunId = created.run_id;
taskRunStream.setRunId(sessionId, created.run_id);
patchAsst({ taskRunId: created.run_id });
if (isBlockedGovernanceStatus(created.status)) {
settled = true;
patchAsst({
status: 'blocked_pending_governance',
content: GOVERNANCE_BLOCKED_HINT,
livePhaseLabel: undefined,
taskRunId: created.run_id,
});
taskRunStream.setRunId(sessionId, null);
taskRunStream.setThinking(sessionId, false);
await chatWindow.refresh().catch(() => undefined);
return;
}
await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal);
return;
}
await streamTask(body, { onEvent }, controller.signal);
};
void startObserver().catch(async (_err) => {
if (settled) return;
if (controller.signal.aborted) {
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
taskRunStream.setThinking(sessionId, false);
return;
}
const runId = retainedRunId || taskRunStream.activeTaskRunId || undefined;
if (runId) {
try {
const remote = await getTaskRun(runId);
if (remote.status === 'quality_failed') {
settled = true;
patchAsst({
status: 'quality_failed',
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
return;
}
if (isBlockedGovernanceStatus(remote.status)) {
settled = true;
patchAsst({
status: 'blocked_pending_governance',
content: GOVERNANCE_BLOCKED_HINT,
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
return;
}
if (remote.status === 'completed') {
settled = true;
patchAsst({
status: 'completed',
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
await syncControlledDocsAfterTask(task.taskKey || 'intake');
return;
}
if (remote.status === 'pending_author_confirmation') {
settled = true;
patchAsst({
status: 'pending_author_confirmation',
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
await syncControlledDocsAfterTask(task.taskKey || 'intake');
await chatWindow.refresh().catch(() => undefined);
return;
}
if (shouldSubscribeExistingRun(remote.status)) {
// 原 run 仍在跑:重订阅,不 POST 新 run
settled = false;
retainedRunId = runId;
patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId });
await subscribeTaskRunEvents(runId, { onEvent }, controller.signal);
return;
}
if (remote.status === 'failed' || remote.status === 'cancelled') {
failOrRetry(buildNetworkErrorInfo());
return;
}
} catch {
// fall through to network retry path
}
}
const httpStatus =
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
? Number((_err as { httpStatus?: number }).httpStatus)
: undefined;
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
failOrRetry(
buildNetworkErrorInfo(httpStatus, {
errorCode: errMeta.errorCode,
detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '),
}),
);
});
};
// 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复)
const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => {
const target = messages.find((m) => m.id === messageId);
if (!target) return;
// 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启
if (target.taskRequest) {
if (action === 'retry') {
const userText = target.taskRequest.userMessage || '';
const retryActive =
deliverables.find((d) => d.docId === activeDocId) ||
visibleDeliverables.find((d) => d.docId === activeDocId);
const reResolved = resolveTaskTemplateForMessage(
userText,
currentProjectDeliverables,
retryActive,
);
const retryTask = reResolved
? {
...target.taskRequest,
taskKey: reResolved.taskKey,
docId: reResolved.docId || target.taskRequest.docId,
}
: target.taskRequest;
runTaskAttempt(retryTask, messageId, 'run', 0);
} else if (action === 'continue') {
const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId;
if (!wsId) return;
runTaskAttempt(target.taskRequest, messageId, 'resume', 0, {
phaseEvents: target.phaseEvents,
workDir: wsId,
});
} else {
if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return;
runTaskAttempt(target.taskRequest, messageId, 'restart', 0);
}
return;
}
// 闲聊路径(P1 行为不变:仅 retry)
if (action !== 'retry') return;
const sourceMsg = target.sourceUserMessageId
? messages.find((m) => m.id === target.sourceUserMessageId)
: undefined;
const originalText = sourceMsg?.content?.trim();
if (!originalText) return;
runStreamAttempt(originalText, messageId, 0, {
userMsgId: sourceMsg?.id,
clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId,
});
};
// 用户主动停止:仅中止当前 session 的活跃 turn
const handleStopStreaming = () => {
turnRuntime.abortActiveSessionTurns();
taskRunStream.abortSession(activeSessionId);
const runId = taskRunStream.activeTaskRunId;
if (runId) {
void cancelTaskRun(runId).catch(() => undefined);
taskRunStream.setRunId(activeSessionId, null);
}
};
const handleLogout = () => {
turnRuntime.abortAllSessions();
taskRunStream.abortAllSessions();
void authClient.logout().finally(() => {
setSession(null);
setCapabilities([]);
setSessionValidated(false);
});
};
// 发送消息与对话驱动双轨逻辑
const handleSendMessage = async (
text: string,
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
quotes?: QuotedMessage[]
): Promise<SendMessageResult> => {
const trimmed = text.trim();
let clientTurnId: string;
try {
clientTurnId = createClientTurnId();
} catch (err) {
return {
accepted: false,
error: err instanceof Error ? err.message : '无法生成发送标识,请重试',
};
}
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
let processedAttachments = attachments;
if (attachments && attachments.length > 0) {
let registered;
try {
registered = await registerUploadAttachments({
attachments,
projectId: currentProjectId,
userId: currentUser.id,
userName: currentUser.name,
createFreeformDoc,
});
} catch (err) {
return {
accepted: false,
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
};
}
const newDeliverablesList = registered.map((r) => r.deliverable);
const newDocCacheEntries: Record<string, DocumentAST> = {};
for (const r of registered) {
const baseTitle = r.deliverable.title;
const initialMd = r.detail.content_markdown;
const newAST = markdownToBlockAST(initialMd, {
docId: r.serverDocId,
title: baseTitle,
taskKey: 'user_requirements',
version: 'V1.0',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
newDocCacheEntries[r.serverDocId] = newAST;
if (r.attachment.fileRaw) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
);
}
}
processedAttachments = registered.map((r) => r.attachment);
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
setDocCache((prev) => ({
...prev,
...newDocCacheEntries,
}));
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
setActiveDocId(processedAttachments[0].docId);
setActiveCategory('private');
}
}
const userMsg: ChatMessage = {
id: 'msg-' + Date.now(),
clientTurnId,
clientMessageId: `${clientTurnId}:user`,
sessionId: activeSessionId,
role: 'user',
authorName: currentUser.name,
content: text,
timestamp: new Date().toTimeString().substring(0, 5),
attachments: processedAttachments,
quotes: quotes && quotes.length > 0 ? quotes : undefined,
};
setActiveQuotes([]);
// 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作)
const isAffirmative = isAffirmativeResponse(trimmed);
const lastActive = findLastActiveCard(messages, activeSessionId);
if (isAffirmative && lastActive) {
const { card } = lastActive;
const defaultAction = card.actions.find((a) => a.isAffirmativeDefault) || card.actions[0];
if (defaultAction) {
setMessages((prev) => [...prev, userMsg]);
handleCardAction(card.cardId, defaultAction.actionId, defaultAction.payload);
return { accepted: true };
}
}
// 递减消息列表中尚未完结的 active 卡片 ttlTurns,若 <= 0 则标记为 expired
setMessages((prev) => [...decrementActiveCardsTtl(prev, activeSessionId), userMsg]);
// 命中以下任一演示用关键词捷径时才走本地模拟分支 (与真实后端 SSE 路径互斥,判定条件与下方分支体一一对应)
const isGenDocShortcut =
text.includes('生成文档') ||
text.includes('起草文档') ||
text.includes('测算') ||
text.includes('推演') ||
text.includes('备忘') ||
text.includes('生成报告');
const isRejectShortcut = text.includes('驳回') || text.includes('退回');
const isModifyShortcut =
(text.includes('修改') || text.includes('更新') || text.includes('调整')) &&
docAST.isClaimed &&
currentDocRole === 'author';
const isReviewPassShortcut =
(text.includes('通过') || text.includes('同意')) &&
((docAST.lifecycleState === 'reviewing' &&
holdsDocDuty(currentUser.id, 'reviewer', {
authorId: docAST.authorId,
reviewerId: docAST.reviewerId,
approverId: docAST.approverId,
})) ||
(docAST.lifecycleState === 'approving' &&
holdsDocDuty(currentUser.id, 'approver', {
authorId: docAST.authorId,
reviewerId: docAST.reviewerId,
approverId: docAST.approverId,
})));
const sendActiveDoc =
deliverables.find((d) => d.docId === activeDocId) ||
visibleDeliverables.find((d) => d.docId === activeDocId);
const resolvedTask = resolveTaskTemplateForMessage(
text,
currentProjectDeliverables,
sendActiveDoc,
);
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
if (resolvedTask) {
const asstId = 'ast-' + Date.now();
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
const clientTurnId = `turn_${Date.now()}`;
const projId = resolveSessionProjectId(currentProj, currentProjectId);
if (projId <= 0) {
console.error('[task] 无法解析会话所属项目', activeSessionId);
return { accepted: false };
}
const taskKey = resolvedTask.taskKey;
const matchedDeliverable = deliverables.find((d) => {
if (d.projectId !== projId) return false;
if (resolvedTask.docId) return d.docId === resolvedTask.docId;
return d.taskKey === taskKey;
});
const activeLease = matchedDeliverable?.editLease;
const leaseIsActive =
!!activeLease?.holderType &&
(!activeLease.expiresAt || new Date(activeLease.expiresAt).getTime() > Date.now());
if (leaseIsActive) {
setMessages((prev) => [
...prev,
{
id: 'ast-busy-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
content: `《${matchedDeliverable?.title || '该文档'}》正在由${activeLease?.holderType === 'squad' ? '项目助手' : '其他用户'}编辑/生成中。当前请求未启动新任务;完成后状态会自动解除。`,
timestamp: new Date().toTimeString().substring(0, 5),
status: 'completed',
},
]);
return { accepted: true };
}
const baseTask: TaskRunContext = {
taskKey,
projectName: currentProj?.title || '医疗器械研发演示项目',
customerName: '北京水木东方',
deviceType: currentProj?.deviceType || '医疗器械',
sessionId: activeSessionId,
userId: currentUser.id,
projectId: projId,
clientTurnId,
userMessage: text,
docId:
resolvedTask.docId ||
canonicalDocIdForTask(
projId,
taskKey,
deliverables,
matchedDeliverable?.docId,
),
};
const placeholder: ChatMessage = {
id: asstId,
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
content: '',
timestamp: new Date().toTimeString().substring(0, 5),
status: 'pending',
livePhaseLabel: '任务执行中…',
taskRequest: baseTask,
};
setMessages((prev) => [...prev, placeholder]);
// 异步读取附件内容后再触发任务,避免文档材料丢失
void (async () => {
const materials = await readAttachmentsAsMaterials(
processedAttachments || [],
baseTask.projectId,
);
const task: TaskRunContext = materials.length > 0 ? { ...baseTask, materials } : baseTask;
runTaskAttempt(task, asstId, 'run', 0);
})();
return { accepted: true };
}
if (!isMockShortcut) {
// 真实后端 SSE 路径:立即挂起「等待中」占位气泡,无人为延迟,随后由 runStreamAttempt 驱动状态机
// (pending → streaming_status → completed/failed/auto_retrying/cancelled),严禁静默兜底话术
const asstId = 'ast-' + Date.now();
const placeholder: ChatMessage = {
id: asstId,
clientTurnId,
clientMessageId: `${clientTurnId}:assistant`,
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
content: '',
timestamp: new Date().toTimeString().substring(0, 5),
status: 'pending',
livePhaseLabel: '助手正在处理…',
sourceUserMessageId: userMsg.id,
};
setMessages((prev) => [...prev, placeholder]);
runStreamAttempt(text, asstId, 0, {
userMsgId: userMsg.id,
clientTurnId,
quote: quotes && quotes.length > 0 ? quotes[0] : undefined,
});
return { accepted: true };
}
taskRunStream.setThinking(activeSessionId, true);
setTimeout(() => {
taskRunStream.setThinking(activeSessionId, false);
// 1. 用户要求助手生成新文档/测算/推演/备忘 (默认自动归档至【我的私密生成】,保护隐私)
if (isGenDocShortcut) {
const newDocId = `doc-private-${Date.now()}`;
const docTitle = text.includes('备忘')
? `${currentUser.name}专属:临床与商业推演备忘录`
: text.includes('测算')
? `${currentUser.name}专属:BOM器件成本与毛利灵敏度测算`
: `${currentUser.name}专属:AI协同专项推演草案`;
const newMeta: DeliverableItem = {
docId: newDocId,
projectId: resolveSessionProjectId(
sessions.find((s) => s.sessionId === activeSessionId),
currentProjectId,
) || 1,
taskKey: 'custom_draft',
title: docTitle,
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().replace('T', ' ').substring(0, 16),
summary: `根据对话中您的需求“${text.slice(0, 60)}...”即时生成的个人私密草稿。`,
};
const sampleMd = `# ${docTitle}\n\n## 1. 产生背景与对话诉求\n根据用户【${currentUser.name}】于 ${new Date().toLocaleTimeString()} 提出的协同要求:“${text}”,参谋小队即时推演生成本专属文档。\n\n> 🔒 [隐私受控标记] 本文档为用户个人私密生成物,仅 ${currentUser.name} 本人可见,不进入项目固定受控审签体系。若推演成熟,可随时点击下方「一键共享给项目组」转为协作参考资料。\n\n## 2. 核心分析与推演结论\n依据系统知识库与工程模型,对关键指标与风险进行分解:\n- 技术可行性评级:A 级(具备清晰工程落地路径)\n- 建议跟进措施:优化元器件批量采购配额,规避长周期交货风险。\n`;
const parsedAst = markdownToBlockAST(sampleMd, {
docId: newDocId,
title: docTitle,
taskKey: 'custom_draft',
version: 'V0.1',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
});
setDeliverables((prev) => [newMeta, ...prev]);
setDocCache((prev) => ({ ...prev, [newDocId]: parsedAst }));
setActiveCategory('private');
setActiveDocId(newDocId);
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🔒 个人私密文档已生成',
content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
// 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物)
if (isRejectShortcut) {
const targetReviewDoc =
docAST.lifecycleState === 'reviewing'
? docAST
: deliverables.find((d) => d.lifecycleState === 'reviewing')
? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST)
: null;
if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') {
const matchingBlock = findMatchingBlockForReview(targetReviewDoc);
const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock);
setMessages((prev) => [
...prev,
{
id: 'ast-reject-gate-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🛡️ 审核驳回门禁',
content: `检测到您对《${targetReviewDoc.title}》提出驳回审核意图。驳回将退回编制阶段并解除锁定,同时向编制人下达整改批注。请确认操作:`,
timestamp: new Date().toTimeString().substring(0, 5),
interactiveCard: card,
},
]);
return;
} else {
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '⚠️ 状态校验提示',
content: `当前受控文档《${docAST.title}》处于【${docAST.lifecycleState}】阶段,非待审核状态,无需执行驳回。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
}
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
if (docAST.isClaimed && currentDocRole === 'author') {
const changes =
'拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。';
const impact = '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。';
const risk = '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。';
const docId = activeDocIdRef.current;
void (async () => {
try {
setProposalError(null);
const prop = await proposeModification({
doc_id: docId,
triggered_by: 'web_ui',
proposed_changes: changes,
impact_analysis: impact,
risk_assessment: risk,
});
setPendingProposal({
proposalId: prop.proposal_id,
changes: prop.proposed_changes || changes,
impact: prop.impact_analysis || impact,
risk: prop.risk_assessment || risk,
});
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🛡️ 文档认领保护机制拦截',
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】(${prop.proposal_id})。请您确认是否准许本次修改。`,
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-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '⚠️ 变更提案失败',
content: `未能创建变更提案:${message}`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
}
})();
return;
}
}
if (text.includes('通过') || text.includes('同意')) {
const duties = {
authorId: docAST.authorId,
reviewerId: docAST.reviewerId,
approverId: docAST.approverId,
};
if (docAST.lifecycleState === 'reviewing' && holdsDocDuty(currentUser.id, 'reviewer', duties)) {
void handlePassReview();
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '📋 审核通过流转',
content: `已确认审核通过!《${docAST.title}》状态已流转至【待批准 (approving)】,已呈报王总监终审。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
if (docAST.lifecycleState === 'approving' && holdsDocDuty(currentUser.id, 'approver', duties)) {
void handleApprove();
return;
}
}
// P2:模板填报演示已由真实 task stream 链路替代(见上方 matchedTemplate 分支)
}, 1000);
return { accepted: true };
};
// 编制人接受变更提议(经 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),
},
]);
}
})();
};
// 块操作:编排层先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
const handleSaveBlockEdit = async (updated: DocumentBlock) => {
const target = deliverables.find((d) => d.docId === activeDocId);
if (
target &&
(target.sourceType === 'uploaded_file' || Boolean(target.fileMeta)) &&
target.governanceMode === 'freeform' &&
!docAST.rootBlock?.children?.length
) {
closeBlockEditor();
return;
}
const result = await saveControlledBlockEdit({
editorDocId: editorSession?.docId,
activeDocId,
ast: docAST,
updated,
actorUserId: currentUser.id,
});
if (!result.ok) {
setDocRevConflict(result.message);
return;
}
editorHydrationGateRef.current.markSaved();
setDocAST(result.ast);
setDocCache((prev) => ({ ...prev, [activeDocId]: result.ast }));
closeBlockEditor();
setDocRevConflict(null);
setDocRevOverlay(result.overlay);
setDocRevRefreshKey((k) => k + 1);
};
const handleInsertBlock = (index: number, position: 'above' | 'below') => {
const newBlock: DocumentBlock = {
id: 'blk_' + Math.random().toString(36).substring(2, 9),
type: 'paragraph',
content: '【新插入技术要求】:在此填写具体受控技术规范或临床评定指标...',
metadata: {
lastModifiedBy: `${currentUser.name} (${currentUser.position})`,
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
},
};
const children = docAST.rootBlock.children || [];
const insertAt = position === 'above' ? index : index + 1;
children.splice(insertAt, 0, newBlock);
recomputeNumbering(docAST.rootBlock);
setDocAST({ ...docAST });
};
const handleMoveBlock = (index: number, direction: 'up' | 'down') => {
const children = docAST.rootBlock.children || [];
const targetIdx = direction === 'up' ? index - 1 : index + 1;
if (targetIdx >= 0 && targetIdx < children.length) {
const temp = children[index];
children[index] = children[targetIdx];
children[targetIdx] = temp;
recomputeNumbering(docAST.rootBlock);
setDocAST({ ...docAST });
}
};
const handleDeleteBlock = (index: number) => {
const children = docAST.rootBlock.children || [];
children.splice(index, 1);
recomputeNumbering(docAST.rootBlock);
setDocAST({ ...docAST });
};
// 批注操作
const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => {
if (!commentingBlock || !canAddBlockComment(currentDocRole)) return;
const newCmt: BlockComment = {
...comment,
id: 'cmt_' + Math.random().toString(36).substring(2, 8),
createdAt: new Date().toTimeString().substring(0, 5),
};
commentingBlock.comments = [...(commentingBlock.comments || []), newCmt];
setDocAST({ ...docAST });
};
const {
handleClaim,
handleSubmitReview,
handlePassReview,
handleRejectReview,
handleApprove,
handleRejectApprove,
} = useDocumentLifecycleActions({
docAST,
setDocAST,
activeDocId,
currentUser,
activeSessionId,
setDeliverables,
setMessages,
});
// 导出 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 原文
const handleExportMarkdown = () => {
const md = blockASTToMarkdown(docAST);
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${docAST.title}_${docAST.version}_受控交付物.md`;
a.click();
URL.revokeObjectURL(url);
};
// 测试演练:动态修改当前用户在某个文档的权限角色
const handleRoleChangeForDoc = (docId: string, role: DocPermissionRole) => {
setCurrentUser((prev) => ({
...prev,
projectPermissions: {
...(prev.projectPermissions || {}),
[docId]: role,
},
}));
};
// 未登录拦截门禁:展示现代化医疗合规登录与注册页面
if (!session) {
return (
<LoginView
onLoginSuccess={(newSession) => {
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}
/>
);
}
// 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员)
const handleCreateProjectConfirm = async (data: CreateProjectConfirmData) => {
if (!beginCreateProjectSubmit(createProjectInFlightRef)) {
return;
}
setCreateProjectError(null);
setCreateProjectSubmitting(true);
setCreateProjectSubmitPhase(
resolveCreateProjectSubmitPhase(createProjectBootstrapIdRef.current),
);
let newProjId: number;
try {
try {
const ensured = await ensureCreatedProjectWithMembers({
bootstrapProjectId: createProjectBootstrapIdRef.current,
createProject: async () => {
const created = await createProject({
name: data.title,
device_type: data.deviceType || undefined,
intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description,
product_domain: data.productDomain,
project_type_key: data.projectTypeKey,
team_mode: data.teamMode,
execution_mode: data.executionMode,
problem_statement: data.problemStatement,
target_users: data.targetUsers,
target_outcome: data.targetOutcome,
initial_workflow_key:
data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined,
});
// create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create
createProjectBootstrapIdRef.current = created.id;
return created;
},
creatorUserId: currentUser.id,
initialMembers: data.initialMembers,
addMember: addProjectMember,
onPhase: setCreateProjectSubmitPhase,
});
newProjId = ensured.projectId;
createProjectBootstrapIdRef.current = ensured.projectId;
} catch (err) {
const withId = err as Error & { projectId?: number };
const bootstrapId =
typeof withId.projectId === 'number' && withId.projectId > 0
? withId.projectId
: createProjectBootstrapIdRef.current;
if (bootstrapId) {
createProjectBootstrapIdRef.current = bootstrapId;
}
const message =
err instanceof Error
? err.message
: '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)';
setCreateProjectError(
bootstrapId
? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。`
: message,
);
return;
}
const nowStr = new Date().toLocaleString();
// 1. 构建项目与成员元数据(服务端成员已持久化)
const members: ProjectMember[] = data.initialMembers.map((m) => ({
userId: m.userId,
userName: m.userName,
department: m.department,
position: m.position,
role: m.role,
status: 'active',
joinedAt: nowStr,
}));
const newProject: ProjectInfo = {
id: newProjId,
title: data.title,
deviceType: data.deviceType,
description: data.description,
creatorId: currentUser.id,
creatorName: currentUser.name,
createdAt: nowStr,
members,
};
// 2. 为每个选中的项目成员生成独立私聊会话
const newSessionsToAppend: ProjectSession[] = [];
const newMessagesToAppend: ChatMessage[] = [];
let creatorSessionId = '';
members.forEach((m) => {
const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId);
if (m.userId === currentUser.id) {
creatorSessionId = sessId;
}
newSessionsToAppend.push({
sessionId: sessId,
userId: m.userId,
projectId: newProjId,
title: data.title,
deviceType: data.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 1,
updatedAt: '刚刚',
});
newMessagesToAppend.push({
id: `m-init-${m.userId}-${Date.now()}`,
sessionId: sessId,
role: 'system',
content:
m.userId === currentUser.id
? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。`
: `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`,
timestamp: new Date().toTimeString().substring(0, 5),
});
});
// 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only
const clonedDocs = shouldCloneMedicalTemplates(data.productDomain)
? createProjectTemplateDeliverables({
projectId: newProjId,
projectName: data.title,
deviceType: data.deviceType,
creatorId: currentUser.id,
creatorName: currentUser.name,
})
: { deliverables: [], docASTMap: {} };
const clonedAsts = clonedDocs.docASTMap;
setProjects((prev) => [newProject, ...prev]);
setSessions((prev) => [...newSessionsToAppend, ...prev]);
setMessages((prev) => [...prev, ...newMessagesToAppend]);
void Promise.all(
newSessionsToAppend.map((sessionItem) =>
upsertChatSession({
session_id: sessionItem.sessionId,
title: sessionItem.title,
user_id: sessionItem.userId ?? currentUser.id,
project_id: sessionItem.projectId,
})
)
).catch((err) => {
console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err);
});
setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]);
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
if (creatorSessionId) {
setActiveSessionId(creatorSessionId);
}
if (clonedDocs.deliverables[0]?.docId) {
setActiveDocId(clonedDocs.deliverables[0].docId);
setActiveCategory('workflow');
}
createProjectBootstrapIdRef.current = null;
setCreateProjectModalOpen(false);
setCreateProjectError(null);
} finally {
endCreateProjectSubmit(createProjectInFlightRef);
setCreateProjectSubmitting(false);
setCreateProjectSubmitPhase(null);
}
};
// 添加成员(持久化至服务端 project_members)
const handleAddProjectMember = async (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
if (!currentProject) return;
try {
await addProjectMember(
currentProject.id,
buildManagedMemberUpsertPayload(
{ userId: newMember.userId, role: newMember.role },
currentProject.creatorId,
),
);
await afterProjectMemberPersisted({
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
});
const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId);
setSessions((prev) => {
const exists = prev.some(
(s) => s.projectId === currentProject.id && s.userId === newMember.userId
);
if (exists) return prev;
return [
{
sessionId: newSessId,
userId: newMember.userId,
projectId: currentProject.id,
title: currentProject.title,
deviceType: currentProject.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 1,
updatedAt: '刚刚',
},
...prev,
];
});
setMessages((prev) => [
...prev,
{
id: `m-join-${newMember.userId}-${Date.now()}`,
sessionId: newSessId,
role: 'system',
content: `【项目协同通知】您已被项目创建人 ${currentProject.creatorName} 加入项目《${currentProject.title}》,并分配项目角色为【${newMember.role}】。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} catch (err) {
setProjectMembersError(err instanceof Error ? err.message : '添加成员失败');
throw err;
}
};
const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => {
if (!currentProject) return;
try {
await updateProjectMember(
currentProject.id,
buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId),
);
await afterProjectMemberPersisted({
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
});
} catch (err) {
setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败');
}
};
const handleRemoveProjectMember = async (userId: number) => {
if (!currentProject) return;
try {
await removeProjectMember(currentProject.id, userId);
await afterProjectMemberPersisted({
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
});
} catch (err) {
setProjectMembersError(err instanceof Error ? err.message : '移出成员失败');
}
};
const handleRestoreProjectMember = async (userId: number) => {
if (!currentProject) return;
try {
const member = currentProject.members.find((m) => m.userId === userId);
await updateProjectMember(
currentProject.id,
buildManagedMemberUpsertPayload(
{ userId, role: member?.role ?? 'member', status: 'active' },
currentProject.creatorId,
),
);
await afterProjectMemberPersisted({
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
});
} catch (err) {
setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败');
}
};
// 全屏系统管理台:与工作台并列的顶层视图,不经过 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 (
<>
<AppShell
sessions={visibleSessions}
activeSessionId={activeSessionId}
onSelectSession={(sessId) => {
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
setActiveSessionId(sessId);
const targetSess = sessions.find((s) => s.sessionId === sessId);
if (targetSess && targetSess.projectId !== currentProjectId) {
const targetProjDocs = deliverables.filter((d) => d.projectId === targetSess.projectId);
if (targetProjDocs.length > 0) {
setActiveDocId(targetProjDocs[0].docId);
setActiveCategory(targetProjDocs[0].category);
}
}
}}
onNewSession={() => setCreateProjectModalOpen(true)}
canCreateProject={canCreateProject}
currentUser={currentUser}
currentRole={currentDocRole}
currentUserName={currentUser.name}
onChangeRole={(_, __) => {}}
onOpenRightsModal={() => setRightsModalOpen(true)}
onOpenUserSettings={() => setUserSettingsOpen(true)}
onOpenAdminSettings={() => setView('admin')}
onOpenProjectMembers={() => setProjectMembersModalOpen(true)}
activeWorkflowKey={currentWfState.activeKey}
workflowVersion={currentWfState.version}
lifecycleStatus={currentWfState.lifecycleStatus}
isCreator={currentWfState.creatorId === currentUser.id}
governanceEnabled={workflowGovernanceEnabled}
onOpenProjectSettings={() => setProjectSettingsOpen(true)}
onWorkflowChanged={(newKey, newVer) => {
setProjectWorkflowsState((prev) => ({
...prev,
[currentProjectId]: {
...currentWfState,
activeKey: newKey,
version: newVer,
lifecycleStatus: 'active',
},
}));
}}
workflowBanner={workflowBanner}
chatContent={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* P1, P2, P3: 顶部分类统计过滤栏、历史日历、三模搜索、智能摘要、导出与未读角标 */}
<CategoryFilterBar
categories={categoryStats}
selectedCategory={chatWindow.selectedCategory}
onSelectCategory={chatWindow.setSelectedCategory}
onNavigateToCategory={(_cat, lastMessageId) => {
if (lastMessageId && activeSessionId) {
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
navigateToAnchor(
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)}
isCalendarOpen={isCalendarOpen}
onOpenSearch={() => setIsSearchOpen(true)}
onOpenDigest={() => setIsDigestOpen(true)}
onOpenExport={() => setIsExportOpen(true)}
unreadTotal={unreadTotal}
/>
{isCalendarOpen && (
<HistoryCalendar
dateStats={dateStats}
unreadMarks={unreadMarks}
onSelectDate={(date) => {
chatWindow.jumpToDate(date);
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
{isSearchOpen && (
<ChatSearchModal
sessionId={activeSessionId}
onSelectHit={(hit) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: hit.message_id },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsSearchOpen(false);
}}
onClose={() => setIsSearchOpen(false)}
/>
)}
{isDigestOpen && (
<DigestTimelineModal
sessionId={activeSessionId}
onJumpToMessage={(messageId) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsDigestOpen(false);
}}
onClose={() => setIsDigestOpen(false)}
/>
)}
{isExportOpen && (
<OnDemand
label="聊天导出"
load={loadChatExportModal}
componentProps={{
isOpen: isExportOpen,
onClose: () => setIsExportOpen(false),
sessionId: activeSessionId,
dayStack: chatWindow.displayDayStack,
}}
/>
)}
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<MessageStream
sessionId={activeSessionId}
dayStack={chatWindow.displayDayStack}
isLoadingOlder={chatWindow.phase === 'loading_older'}
hasMoreBefore={chatWindow.hasMoreBefore}
onLoadOlder={chatWindow.loadOlder}
onLoadFullDay={chatWindow.loadFullDay}
onOpenCase={handleSelectDoc}
onCardAction={handleCardAction}
onErrorAction={handleErrorAction}
onQuote={handleQuoteMessage}
onNavigateToAnchor={(targetAnchor) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: targetAnchor },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}}
selectedCategory={chatWindow.selectedCategory}
unreadMessageIds={unreadMessageIds}
onSaveToPrivate={handleSaveMessageToPrivate}
onExportDocx={handleExportReplyDocx}
onOpenDoc={handleSelectDoc}
/>
<CategoryDock
categories={categoryStats}
activeCategory={chatWindow.selectedCategory}
messages={chatWindow.allMessages}
unreadTotal={unreadTotal}
unreadMessageIds={unreadMessageIds}
isAllReadCompleted={isAllReadCompleted}
onConsumeNextUnread={async () => {
const targetId = await consumeNextUnread();
if (targetId && activeSessionId) {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: targetId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onNavigateToCategory={(_cat, lastMessageId) => {
if (lastMessageId && activeSessionId) {
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
navigateToAnchor(
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onSelectCategory={chatWindow.setSelectedCategory}
/>
</div>
{pendingProposal && (
<div style={{ padding: '0 16px' }}>
{proposalError && (
<div style={{ color: 'var(--danger, #b91c1c)', fontSize: 12, marginBottom: 8 }}>
{proposalError}
</div>
)}
<ProposalConfirmCard
proposedChanges={pendingProposal.changes}
impactAnalysis={pendingProposal.impact}
potentialRisk={pendingProposal.risk}
onAccept={handleAcceptProposal}
onReject={handleRejectProposal}
/>
</div>
)}
<InputArea
onSendMessage={handleSendMessage}
isThinking={isThinking}
isStreaming={isStreamingActive}
onStopStreaming={handleStopStreaming}
quotes={activeQuotes}
onRemoveQuote={handleRemoveQuote}
disabled={isCurrentProjectExited}
disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
/>
</div>
}
docContent={
isCurrentProjectExited ? (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '40px 30px',
background: 'var(--bg-subtle, #f8fafc)',
textAlign: 'center',
}}
>
<div
style={{
width: '64px',
height: '64px',
borderRadius: '16px',
background: 'rgba(239, 68, 68, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#dc2626',
marginBottom: '16px',
}}
>
<ShieldAlert size={32} />
</div>
<h3 style={{ margin: '0 0 8px', fontSize: '18px', fontWeight: 600, color: 'var(--text-strong, #0f172a)' }}>
受控文档已执行合规脱密隔离
</h3>
<p
style={{
margin: 0,
maxWidth: '440px',
fontSize: '13px',
color: 'var(--text-muted, #64748b)',
lineHeight: 1.6,
}}
>
您已退出该研发项目。依据医疗器械质量管理体系 (GMP/ISO 13485) 及商业保密规程,本项目技术规格书、设计输入草案与验证交付物已对您执行访问切断。历史聊天记录仅供审计留痕查阅。
</p>
</div>
) : (
<div
style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', position: 'relative' }}
data-testid="active-document-pane"
data-doc-id={activeDocId}
data-doc-title={
deliverables.find((d) => d.docId === activeDocId)?.title ||
visibleDeliverables.find((d) => d.docId === activeDocId)?.title ||
''
}
>
{/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */}
<SlimTabBar
deliverables={visibleDeliverables}
activeDocId={activeDocId}
activeCategory={activeCategory}
onSelectCategory={setActiveCategory}
onSelectDoc={handleSelectDoc}
onToggleDeckDrawer={() => setDeckDrawerOpen(!deckDrawerOpen)}
onExportDocx={handleExportDocx}
onExportXlsx={handleExportXlsx}
onExportMarkdown={handleExportMarkdown}
/>
{(() => {
const revDoc =
deliverables.find((d) => d.docId === activeDocId) ||
visibleDeliverables.find((d) => d.docId === activeDocId);
const mode = revisionToolbarMountMode(
revDoc ?? { docId: activeDocId, isTemplateOnly: true },
);
if (mode === 'toolbar' && revDoc) {
return (
<DocumentRevisionToolbar
docId={activeDocId}
overlayMode={docRevOverlayMode}
onOverlayModeChange={setDocRevOverlayMode}
onOverlay={setDocRevOverlay}
revisionRefreshKey={docRevRefreshKey}
/>
);
}
const hint = documentRevisionInactiveHint(
revDoc ?? { docId: activeDocId, isTemplateOnly: true },
);
if (!hint) return null;
return (
<div
data-testid="doc-rev-template-hint"
className="doc-rev-template-hint"
style={{
padding: '6px 12px',
borderBottom: '1px solid var(--border, #ddd)',
fontSize: 12,
color: 'var(--text-muted, #64748b)',
background: 'var(--bg-subtle, #f8fafc)',
}}
>
{hint}
</div>
);
})()}
{docRevConflict && (
<div role="alert" style={{ padding: '8px 12px', background: '#fff3cd', borderBottom: '1px solid #e0c36a' }}>
{docRevConflict}
<button type="button" style={{ marginLeft: 8 }} onClick={() => window.location.reload()}>
刷新后重做
</button>
</div>
)}
{docRevOverlayMode === 'manual' && docRevOverlay && docRevOverlay.hunks?.length > 0 && (
<div style={{ fontSize: 12, padding: '4px 12px', opacity: 0.85 }}>
人工差异 {docRevOverlay.summary.modifies + docRevOverlay.summary.inserts + docRevOverlay.summary.deletes} 处
</div>
)}
{/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */}
<DeckDrawer
isOpen={deckDrawerOpen}
onClose={() => setDeckDrawerOpen(false)}
deliverables={visibleDeliverables}
activeDocId={activeDocId}
currentUserId={currentUser.id}
onSelectDoc={handleSelectDoc}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
onDeleteDoc={handleDeleteDoc}
onOpenDocVisibility={(doc) => {
setTargetDocForVisibility(doc);
setDocVisibilityModalOpen(true);
}}
/>
{/* 核心文档内容渲染区:用户上传文档使用高保真文件预览器,工作流及对话入库文档使用结构化 BlockRenderer */}
{(() => {
const activeDeliverable = deliverables.find((d) => d.docId === activeDocId);
const isUploadedFile = Boolean(
activeDeliverable &&
(activeDeliverable.sourceType === 'uploaded_file' ||
Boolean(activeDeliverable.fileMeta) ||
activeDeliverable.docId.startsWith('doc-upload-') ||
Boolean(activeDeliverable.title?.match(/\.(docx?|pdf|xlsx?|csv|pptx?|png|jpe?g|svg|webp|txt|md|json)$/i)) ||
(activeDeliverable.taskKey === 'user_requirements' &&
!activeDeliverable.isFixedWorkflow &&
(activeDeliverable.category === 'private' || activeDeliverable.category === 'shared')))
);
if (isUploadedFile && activeDeliverable) {
return (
<OnDemand
label="文件预览"
load={loadFilePreviewPanel}
componentProps={{
deliverable: activeDeliverable,
onOpenDocVisibility: () => {
setTargetDocForVisibility(activeDeliverable);
setDocVisibilityModalOpen(true);
},
}}
/>
);
}
return (
<>
{docLoadErrors[activeDocId] ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '10px 16px',
background: 'rgba(180, 60, 40, 0.08)',
borderBottom: '1px solid rgba(180, 60, 40, 0.2)',
fontSize: 13,
}}
>
<span>
{docLoadErrorInfo[activeDocId]
? [
docLoadErrorInfo[activeDocId].code,
docLoadErrorInfo[activeDocId].message,
docLoadErrorInfo[activeDocId].correlationId
? `corr=${docLoadErrorInfo[activeDocId].correlationId}`
: '',
]
.filter(Boolean)
.join(' ')
: '受控文档正文加载失败,请检查网络后重试。'}
</span>
<button
type="button"
onClick={retryControlledDocLoad}
style={{
border: '1px solid rgba(180, 60, 40, 0.35)',
background: '#fff',
borderRadius: 6,
padding: '4px 12px',
cursor: 'pointer',
}}
>
点击重试
</button>
</div>
) : null}
<BlockRenderer
ast={docAST}
currentRole={currentDocRole}
onEditBlock={openBlockEditor}
onInsertBlock={handleInsertBlock}
onMoveBlock={handleMoveBlock}
onDeleteBlock={handleDeleteBlock}
onOpenComments={setCommentingBlock}
overlayMode={docRevOverlayMode}
overlayHunks={docRevOverlay?.hunks || []}
onAskAgent={(snippet) => {
handleSendMessage(
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
);
}}
highlightedBlockId={highlightedBlockId}
/>
</>
);
})()}
{/* 底部流转动作条 (受控流程文档使用 DocGovActionBar,自由私密/共享文档使用 FreeformDocumentActionBar) */}
{docAST.governanceMode === 'freeform' || docAST.space === 'my_private' || docAST.space === 'project_shared' || docAST.category === 'private' || docAST.category === 'shared' ? (
<FreeformDocumentActionBar
ast={docAST}
currentUserId={currentUser.id}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
onDeleteDoc={handleDeleteDoc}
onCopyRich={() => copyRichReplyToClipboard(blockASTToMarkdown(docAST))}
onCopyMarkdown={() => copyMarkdownReply(blockASTToMarkdown(docAST))}
onDownloadMarkdown={() => downloadMarkdownReply(blockASTToMarkdown(docAST), docAST.title)}
onDownloadDocx={handleExportDocx}
/>
) : (
<DocGovActionBar
ast={docAST}
currentRole={currentDocRole}
currentUserId={currentUser.id}
canOverrideVisibility={canOverrideVisibility}
onOpenVisibilityModal={() => {
const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null;
setTargetDocForVisibility(currentDoc);
setDocVisibilityModalOpen(true);
}}
onClaim={handleClaim}
onSubmitReview={handleSubmitReview}
onPassReview={handlePassReview}
onRejectReview={handleRejectReview}
onApprove={handleApprove}
onRejectApprove={handleRejectApprove}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
/>
)}
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
{editorSession && (
<OnDemand
label="块编辑器"
load={loadBlockEditorModal}
componentProps={{
block: editorSession.block,
actorLabel: `${currentUser.name} (${currentUser.position || '成员'})`,
onSave: handleSaveBlockEdit,
onClose: closeBlockEditor,
}}
/>
)}
{/* 审核批注侧栏 */}
{commentingBlock && (
<CommentSidePanel
block={commentingBlock}
currentRole={currentDocRole}
currentUserName={currentUser.name}
onAddComment={handleAddComment}
onClose={() => setCommentingBlock(null)}
/>
)}
</div>
)
}
/>
{/* 用户在当前激活项目中的法定职责与权限矩阵弹窗 */}
<UserProjectRightsModal
isOpen={rightsModalOpen}
onClose={() => setRightsModalOpen(false)}
currentUser={currentUser}
currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'}
deliverables={visibleDeliverables}
activeDocId={activeDocId}
canAssignLead={canAssignLead}
onSelectDoc={handleSelectDoc}
onRoleChangeForDoc={handleRoleChangeForDoc}
/>
{/* 首次登录强制改密阻断弹窗 */}
<FirstLoginPasswordModal
isOpen={!!session?.mustChangePassword}
user={currentUser}
onPasswordChanged={() => {
const updated = { ...session, mustChangePassword: false };
setSession(updated);
authClient.saveSession(updated);
}}
onLogout={handleLogout}
/>
{/* 用户个人设置与偏好中心 (全员可用) */}
<UserSettingsModal
isOpen={userSettingsOpen}
onClose={() => setUserSettingsOpen(false)}
currentUser={currentUser}
roles={roles}
onUpdateProfile={(updatedProfile) => {
setAllUsers((prev) =>
prev.map((u) => (u.id === currentUser.id ? { ...u, ...updatedProfile } : u))
);
setCurrentUser((prev) => ({ ...prev, ...updatedProfile }));
}}
onLogout={handleLogout}
/>
{/* 单文档可见性覆盖弹窗 */}
<DocVisibilityModal
isOpen={docVisibilityModalOpen}
onClose={() => setDocVisibilityModalOpen(false)}
doc={targetDocForVisibility}
roles={roles}
currentUserName={currentUser.name}
onSaveRule={handleSaveDocVisibilityRule}
/>
{/* 发起医疗器械新项目立项弹窗 */}
<CreateProjectModal
isOpen={createProjectModalOpen}
onClose={() => {
if (createProjectSubmitting) return;
setCreateProjectModalOpen(false);
setCreateProjectError(null);
}}
onSubmit={handleCreateProjectConfirm}
submitError={createProjectError}
submitting={createProjectSubmitting}
submitPhase={createProjectSubmitPhase}
currentUser={currentUser}
availableUsers={allUsers}
/>
{/* 项目成员与职责分配管理弹窗 */}
<ProjectMembersModal
isOpen={projectMembersModalOpen}
onClose={() => setProjectMembersModalOpen(false)}
project={currentProject}
currentUser={currentUser}
availableUsers={allUsers}
canManage={canManageProjectMembers}
membersError={projectMembersError}
onAddMember={handleAddProjectMember}
onUpdateMemberRole={handleUpdateProjectMemberRole}
onRemoveMember={handleRemoveProjectMember}
onRestoreMember={handleRestoreProjectMember}
/>
<ProjectSettingsModal
open={projectSettingsOpen}
projectId={currentProjectId}
projectName={currentActiveSession?.title || '当前项目'}
members={(currentProject?.members || [])
.filter((m) => m.status === 'active')
.map((m) => ({
userId: m.userId,
name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`,
}))}
onClose={() => setProjectSettingsOpen(false)}
onActivated={(activeKey) => {
setProjectWorkflowsState((prev) => ({
...prev,
[currentProjectId]: {
...currentWfState,
activeKey,
lifecycleStatus: 'active',
},
}));
}}
/>
</>
);
}