Files
Agent-System/web/src/App.tsx
T
WeiCHEN 1fc7d0d01f feat(export): 实现单次智能体回复导出、私密文档保存与隐私防泄露全套闭环 (Phase 0 - Phase 2)
- Phase 0: 实现服务端回复导出策略矩阵引擎 (replyexport.EvaluatePolicy)、数据库迁移 (2026-09-11-message-export-policy.sql)、SSE done 事件挂载与能力探测 API
- Phase 1: 构建前端 ReplyDocumentIR 语法树解析与渲染管道,支持 Markdown / DOCX 文件下载及带格式富文本剪贴板复制
- Phase 2: 实现私密自由文档 (freeform_documents) 领域服务、PostgreSQL 乐观锁版本管理、ACL 隐私隔离、一键共享与右侧栏编辑联动
- 专项修复:
  1. 修复后台 API 路由注册并加入双向拉取同步,确保私密保存实时进入我的私密列表
  2. 实现轻量回复专属 DOCX 生成与静默下载逻辑 (downloadDocxReply)
  3. 将 LaTeX 公式转为标准 MathML 矢量标记,支持粘贴到 Microsoft Word / WPS 自动识别为原生公式
2026-09-13 02:50:21 +00:00

2508 lines
93 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 } 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 { BlockEditorModal } from './components/doc/BlockEditorModal';
import { CommentSidePanel } from './components/doc/CommentSidePanel';
import { DocGovActionBar } from './components/doc/DocGovActionBar';
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
import {
saveMessageToPrivate,
listFreeformDocs,
fetchFreeformDoc,
shareFreeformDoc,
unshareFreeformDoc,
deleteFreeformDoc,
} from './api/freeformDoc';
import { deriveReplyTitle } from './features/replyExport/title';
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport/download';
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
import { UserSettingsModal } from './components/user/UserSettingsModal';
import { AdminSettingsModal } from './components/admin/AdminSettingsModal';
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
import { LoginView } from './components/auth/LoginView';
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
import { DevRoleSwitcher } from './components/dev/DevRoleSwitcher';
import { authClient } from './api/auth';
import { streamChat, mapErrorInfo } from './api/chatStream';
import { streamTask, buildTaskRequest, buildResumeRequest } from './api/taskStream';
import { shouldAutoRetry, 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,
} from './types/workbench';
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
import { RoleDefinition, DocVisibilityRule } from './types/rbac';
import {
DEFAULT_ROLES,
hasPermission,
filterVisibleDeliverablesWithRBAC,
} from './engine/rbac';
import { MOCK_DOCUMENTS } from './data/mockDocuments';
import {
NEUROPULSE_DELIVERABLES,
NEUROPULSE_DOCUMENTS,
NEUROPULSE_PERMISSIONS,
} from './data/neuropulseDemo';
import { exportDocumentToDocx } from './engine/docxRenderer';
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
import {
findMatchingBlockForReview,
createRejectConfirmationCard,
isAffirmativeResponse,
findLastActiveCard,
decrementActiveCardsTtl,
applyConfirmRejectToDoc,
applyUndoRejectToDoc,
resolveCardInMessages,
cancelCardInMessages,
} from './engine/cardActionEngine';
import { createProjectTemplateDeliverables } from './engine/templateFactory';
import {
loadSessions,
saveSessions,
loadActiveSessionId,
saveActiveSessionId,
loadMessages,
saveMessages,
loadDeliverables,
saveDeliverables,
loadRoles,
saveRoles,
loadUsers,
saveUsers,
migrateAndClearLegacyMessages,
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
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 { ChatExportModal } from './components/history/ChatExportModal';
import { useUnreadMarks } from './hooks/useUnreadMarks';
import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
export const INITIAL_DELIVERABLES: DeliverableItem[] = [
{
docId: 'doc-001',
projectId: 1,
taskKey: 'feasibility_reg',
title: '4K荧光内窥镜立项与可行性报告',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 14:15',
summary: '针对4K荧光腹腔镜分类界定、临床评价路径与法规标准准入全景评估。',
},
{
docId: 'doc-002',
projectId: 1,
taskKey: 'research',
title: '微创外科全球竞品深度调研',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'reviewing',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 11:30',
summary: '针对史赛克1688、卡尔史托斯Rubina荧光内窥镜性能全方位对比分析。',
},
{
docId: 'doc-003',
projectId: 1,
taskKey: 'prelim_design',
title: '光学分光与电路初步设计规格书',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'approving',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-07 17:00',
summary: '4K荧光双路分光棱镜光路设计与近红外消色差物镜参数设计。',
},
{
docId: 'doc-004',
projectId: 1,
taskKey: 'cdo_quote',
title: 'CDO商业报价单与工时清单',
stage: 'V1.0',
category: 'workflow',
creatorId: 4,
creatorName: '王商务',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '王商务',
reviewer: '张工',
approver: '李总',
updatedAt: '2026-09-08 15:30',
summary: '基于WBS千工时分解与CDO成本模型的真实商业报价核算。',
},
{
docId: 'doc-shared-001',
projectId: 1,
taskKey: 'supply_chain_analysis',
title: '4K双路CMOS供应链与成本灵敏度分析',
stage: 'V0.5',
category: 'shared',
creatorId: 1,
creatorName: '张工',
sharedAt: '2026-09-08 15:20',
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 15:20',
summary: '张工在与助手对话中推演的 Sony IMX485 与国产长光辰芯双路 CMOS 传感器成本灵敏度测算。',
},
{
docId: 'doc-private-001',
projectId: 1,
taskKey: 'negotiation_memo',
title: '微创医疗战略商务谈判底线与预付款让利推演备忘',
stage: 'V1.0',
category: 'private',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 16:10',
summary: '张工个人与助手对话进行的商务谈判底线、毛利率红线测算草案,含个人思考,仅本人可见。',
},
{
docId: 'doc-private-002',
projectId: 1,
taskKey: 'lead_capacity_memo',
title: '团队绩效核算与阶段资源饱和度评估',
stage: 'V0.5',
category: 'private',
creatorId: 2,
creatorName: '李主管',
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '李主管',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 15:50',
summary: '李主管个人对话生成的团队工时饱和度与绩效预算底稿,仅李主管可见。',
},
];
// 为默认第二项目 (便携式超声探头,projectId: 2) 预置全套 7 份生命周期模板副本
const P2_INITIAL = createProjectTemplateDeliverables({
projectId: 2,
projectName: '便携式掌上无线超声诊断仪',
deviceType: '便携式超声探头',
creatorId: 1,
creatorName: '张工',
});
// 为第三测试项目 (数字化高频超声手术吸引系统 CUSA-Ultra,projectId: 3) 预置交付物
export const CUSA_INITIAL_DELIVERABLES: DeliverableItem[] = [
{
docId: 'doc-cusa-intake',
projectId: 3,
taskKey: 'intake',
title: '数字化高频超声手术吸引系统立项需求与产品定义',
stage: 'V0.1',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'approved',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 17:00',
summary: '高频超声手术吸引系统 (CUSA-Ultra) 初始需求摸底、预期临床用途与整机工程规格基线。',
},
{
docId: 'doc-cusa-feasibility-reg',
projectId: 3,
taskKey: 'feasibility_reg',
title: '数字化高频超声手术吸引系统立项与可行性法规论证报告',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'approved',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 17:30',
summary: '针对 CUSA 超声吸引刀头与主机的三类医疗器械分类准入、注册检验与临床评价路径全景评估。',
},
{
docId: 'doc-cusa-free-cavitation',
projectId: 3,
taskKey: 'free_cusa_cavitation_risk',
title: '24kHz高频空化钛合金刀头微裂纹与疲劳剥落风险专项分析报告',
stage: 'V0.5',
category: 'shared',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'approved',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 17:15',
summary: '自由任务产出:基于超声高频交变应力与空化微射流冲击的钛合金刀头疲劳寿命与表面钝化防护策略。',
},
{
docId: 'doc-cusa-quote',
projectId: 3,
taskKey: 'cdo_quote',
title: 'CDO超声手术系统工程样机研发与型检预算投资核算单',
stage: 'V1.0',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 17:45',
summary: 'CDO 商业立项核算清单:高频超声驱动电源、压电换能器、钛合金刀头模具及三类型检规费。',
},
];
export const ALL_INITIAL_DELIVERABLES: DeliverableItem[] = [
...NEUROPULSE_DELIVERABLES,
...INITIAL_DELIVERABLES,
...P2_INITIAL.deliverables,
...CUSA_INITIAL_DELIVERABLES,
];
export const ALL_MOCK_DOCUMENTS = {
...MOCK_DOCUMENTS,
...NEUROPULSE_DOCUMENTS,
};
export const MOCK_USERS: UserProfile[] = [
{
id: 1,
name: '张工',
employeeId: 'SMDF-0821',
department: '机器人与微创内窥镜研发中心',
position: '资深医疗器械方案工程师',
roleId: 'role-engineer',
projectPermissions: {
'doc-001': 'author', // 编制人
'doc-002': 'reviewer', // 审核人
'doc-003': 'approver', // 批准人
'doc-004': 'viewer', // 查阅者
'doc-shared-001': 'author',
'doc-private-001': 'author',
'doc-cusa-intake': 'author',
'doc-cusa-feasibility-reg': 'author',
'doc-cusa-free-cavitation': 'author',
'doc-cusa-quote': 'author',
...(NEUROPULSE_PERMISSIONS['1'] || {}),
},
},
{
id: 2,
name: '李主管',
employeeId: 'SMDF-0205',
department: '机器人与微创内窥镜研发中心',
position: '研发项目技术主管',
roleId: 'role-tech-lead',
projectPermissions: {
'doc-001': 'reviewer',
'doc-002': 'author',
'doc-003': 'reviewer',
'doc-004': 'viewer',
'doc-shared-001': 'viewer',
'doc-private-002': 'author',
'doc-cusa-intake': 'reviewer',
'doc-cusa-feasibility-reg': 'reviewer',
'doc-cusa-free-cavitation': 'reviewer',
'doc-cusa-quote': 'reviewer',
...(NEUROPULSE_PERMISSIONS['2'] || {}),
},
},
{
id: 3,
name: '王总监',
employeeId: 'SMDF-0018',
department: '医疗器械研发事业部',
position: '微创器械研发总监',
roleId: 'role-director',
projectPermissions: {
'doc-001': 'approver',
'doc-002': 'approver',
'doc-003': 'author',
'doc-004': 'approver',
'doc-shared-001': 'approver',
'doc-cusa-intake': 'approver',
'doc-cusa-feasibility-reg': 'approver',
'doc-cusa-free-cavitation': 'approver',
'doc-cusa-quote': 'approver',
...(NEUROPULSE_PERMISSIONS['3'] || {}),
},
},
{
id: 4,
name: '赵管理员',
employeeId: 'SMDF-0001',
department: '系统安全与技术委员会',
position: '系统全域管理总管',
roleId: 'role-admin',
projectPermissions: {
'doc-001': 'approver',
'doc-002': 'approver',
'doc-003': 'approver',
},
},
{
id: 5,
name: '钱审计',
employeeId: 'SMDF-9901',
department: '第三方合规审计与认证机构',
position: '外部合规审查专员',
roleId: 'role-guest',
projectPermissions: {},
},
];
export default function App() {
// 会话与用户状态 (支持 localStorage 刷新持久化)
const [sessions, setSessions] = useState<ProjectSession[]>(() => loadSessions());
const [activeSessionId, setActiveSessionId] = useState<string>(() => loadActiveSessionId('sess-001'));
// 自动持久化 sessions 与 activeSessionId
useEffect(() => {
saveSessions(sessions);
}, [sessions]);
useEffect(() => {
saveActiveSessionId(activeSessionId);
}, [activeSessionId]);
// 全局角色库与人员名单状态 (支持动态增删改与持久化)
const [roles, setRoles] = useState<RoleDefinition[]>(() => loadRoles(DEFAULT_ROLES));
const [allUsers, setAllUsers] = useState<UserProfile[]>(() => loadUsers(MOCK_USERS));
// 自动持久化 roles 与 allUsers
useEffect(() => {
saveRoles(roles);
}, [roles]);
useEffect(() => {
saveUsers(allUsers);
}, [allUsers]);
// 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责)
const [currentUser, setCurrentUser] = useState<UserProfile>(() => allUsers[0] || MOCK_USERS[0]);
// 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类,支持持久化)
const [deliverables, setDeliverables] = useState<DeliverableItem[]>(() =>
loadDeliverables(ALL_INITIAL_DELIVERABLES)
);
// 自动持久化 deliverables
useEffect(() => {
saveDeliverables(deliverables);
}, [deliverables]);
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
const [activeDocId, setActiveDocId] = useState<string>(() => {
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
if (activeSess?.projectId === 5201) return 'doc-neuropulse-feasibility_reg';
if (activeSess?.projectId === 3) return 'doc-cusa-feasibility-reg';
return 'doc-001';
});
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
// 认证与会话状态
const [session, setSession] = useState<LoginResult | null>(() => authClient.loadSavedSession());
const [systemEnv, setSystemEnv] = useState<string>('dev');
const [systemAuthConfig, setSystemAuthConfig] = useState<SystemAuthConfig>({
allowRegistration: true,
requireApproval: false,
});
// 模态浮层控制
const [rightsModalOpen, setRightsModalOpen] = useState(false);
const [userSettingsOpen, setUserSettingsOpen] = useState(false);
const [adminSettingsOpen, setAdminSettingsOpen] = useState(false);
const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false);
const [targetDocForVisibility, setTargetDocForVisibility] = useState<DeliverableItem | null>(null);
useEffect(() => {
authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {});
const saved = authClient.loadSavedSession();
if (saved) {
setSession(saved);
if (saved.env) setSystemEnv(saved.env);
const match = allUsers.find((u) => u.id === saved.user.id);
if (match) {
setCurrentUser(match);
} else {
setCurrentUser(saved.user);
}
}
}, []);
const handleLogout = () => {
authClient.logout();
setSession(null);
};
// 核心功能权限计算
const canCreateProject = useMemo(() => {
return hasPermission(currentUser, roles, 'project:create');
}, [currentUser, roles]);
const canAssignLead = useMemo(() => {
return hasPermission(currentUser, roles, 'project:assign_lead');
}, [currentUser, roles]);
const canOverrideVisibility = useMemo(() => {
return (
hasPermission(currentUser, roles, 'doc:override_visibility') ||
hasPermission(currentUser, roles, 'perm:manage')
);
}, [currentUser, roles]);
// 获取当前激活会话的项目ID (默认为 1)
const currentProjectId = useMemo(() => {
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
return activeSess ? activeSess.projectId : 1;
}, [sessions, activeSessionId]);
// 严格按 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
const [docCache, setDocCache] = useState<Record<string, DocumentAST>>(() => {
const initialCache: Record<string, DocumentAST> = { ...P2_INITIAL.docASTMap };
Object.entries(ALL_MOCK_DOCUMENTS).forEach(([id, meta]) => {
initialCache[id] = markdownToBlockAST(meta.markdown, {
docId: meta.docId,
title: meta.title,
taskKey: meta.taskKey,
version: meta.version,
category: meta.category,
creatorId: meta.creatorId,
creatorName: meta.creatorName,
sharedAt: meta.sharedAt,
lifecycleState: meta.lifecycleState,
isClaimed: meta.isClaimed,
author: meta.author,
reviewer: meta.reviewer,
approver: meta.approver,
});
});
return initialCache;
});
const docAST =
docCache[activeDocId] ||
(visibleDeliverables[0] && docCache[visibleDeliverables[0].docId]) ||
docCache['doc-neuropulse-feasibility_reg'] ||
docCache['doc-001'];
// 根据当前激活文档与用户权限矩阵,计算当前生效角色
const currentDocRole: UserRole = (() => {
const perm = currentUser.projectPermissions?.[activeDocId];
if (perm === 'author' || perm === 'reviewer' || perm === 'approver') {
return perm;
}
// 降级回退
if (docAST.author === currentUser.name) return 'author';
if (docAST.reviewer === currentUser.name) return 'reviewer';
if (docAST.approver === currentUser.name) return 'approver';
return 'author'; // 默认
})();
const setDocAST = (newAst: DocumentAST) => {
setDocCache((prev) => ({
...prev,
[activeDocId]: newAst,
}));
};
// 切换文档
const handleSelectDoc = (id: string) => {
setActiveDocId(id);
const targetDoc = deliverables.find((d) => d.docId === id);
if (targetDoc) {
setActiveCategory(targetDoc.category);
}
if (!docCache[id]) {
if (ALL_MOCK_DOCUMENTS[id]) {
const meta = ALL_MOCK_DOCUMENTS[id];
const parsed = markdownToBlockAST(meta.markdown, {
docId: meta.docId,
title: meta.title,
taskKey: meta.taskKey,
version: meta.version,
category: meta.category,
creatorId: meta.creatorId,
creatorName: meta.creatorName,
sharedAt: meta.sharedAt,
lifecycleState: meta.lifecycleState,
isClaimed: meta.isClaimed,
author: meta.author,
reviewer: meta.reviewer,
approver: meta.approver,
});
setDocCache((prev) => ({ ...prev, [id]: parsed }));
} else if (targetDoc) {
// 健壮兜底:即使为自定义/克隆交付物,也能即时生成基础 AST 骨架,严禁卡死或回退到无关文档
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, currentUser.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,
currentUser.id
);
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;
}
};
// 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables
useEffect(() => {
let active = true;
const fetchDocs = async () => {
try {
const [privateSummaries, sharedSummaries] = await Promise.all([
listFreeformDocs('my_private', currentProjectId, currentUser.id).catch(() => []),
listFreeformDocs('project_shared', currentProjectId, currentUser.id).catch(() => []),
]);
if (!active) return;
const allSummaries = [...privateSummaries, ...sharedSummaries];
if (allSummaries.length === 0) return;
setDeliverables((prev) => {
let updated = [...prev];
for (const s of allSummaries) {
const exists = updated.find((d) => d.docId === s.doc_id);
const category = s.space === 'my_private' ? 'private' : 'shared';
if (!exists) {
updated.unshift({
docId: s.doc_id,
projectId: s.project_id || currentProjectId,
taskKey: 'freeform_doc',
title: s.title,
stage: 'V0.1',
category,
creatorId: s.owner_user_id || currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
governanceMode: 'freeform',
space: s.space,
});
}
}
return updated;
});
} catch (e) {
console.warn('获取服务端自由文档列表异常:', e);
}
};
fetchDocs();
return () => {
active = false;
};
}, [currentProjectId, currentUser.id]);
// 模态弹窗与侧栏
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
const [pendingProposal, setPendingProposal] = useState<{
changes: string;
impact: string;
risk: string;
patchBlockId?: string;
} | null>(null);
// 消息流与思考脉冲 (支持 localStorage 持久化与项目会话严格隔离)
const [isThinking, setIsThinking] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>(() => loadMessages());
// 真实后端 SSE 流式对话:当前活跃请求的中止句柄 (支持停止与自动重试取消)
const streamAbortRef = useRef<AbortController | null>(null);
// 自动持久化消息流
useEffect(() => {
saveMessages(messages);
}, [messages]);
// 基于当前激活的项目会话严格隔离对话记录
const currentSessionMessages = useMemo(() => {
return messages.filter((m) => m.sessionId === activeSessionId);
}, [messages, activeSessionId]);
// P0: 会话按日窗口状态机 (处理首屏拉取、瞬时落底、触顶加载补偿)
const chatWindow = useChatWindow({
sessionId: activeSessionId,
liveMessages: currentSessionMessages,
});
// 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(activeSessionId, currentUser?.id);
// 会话切换时加载分类与日期统计
useEffect(() => {
if (!activeSessionId) 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]);
// 监听 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]);
useEffect(() => {
const handleHashChange = () => {
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));
};
// 挂载时检测并迁移旧版 localStorage 巨大 MESSAGES 键
useEffect(() => {
migrateAndClearLegacyMessages();
}, []);
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 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;
}
}
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,
title: meta.title,
taskKey: meta.taskKey,
version: meta.version,
category: meta.category,
creatorId: meta.creatorId,
creatorName: meta.creatorName,
sharedAt: meta.sharedAt,
lifecycleState: meta.lifecycleState,
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: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
},
],
},
]);
}
};
// 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用)
const buildNetworkErrorInfo = (): ChatErrorInfo => ({
userMessage: '网络连接异常,请检查网络后重试',
code: 'NETWORK_ERROR',
class: fallbackErrorClass({ network: true }),
actions: ['retry'],
});
// 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled,
// 瞬态错误在 attemptIndex < 2 时自动重试 (1s/2s),其余情况落地为结构化失败态 (P1:展示动作仅保留“重试”)
const runStreamAttempt = (
userText: string,
asstId: string,
attemptIndex: number,
quote?: QuotedMessage
) => {
// 同一时刻只允许一个活跃流:新尝试前中止上一个 (若存在)
streamAbortRef.current?.abort();
const controller = new AbortController();
streamAbortRef.current = controller;
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
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 });
setIsThinking(false);
return;
}
runStreamAttempt(userText, asstId, attemptIndex + 1, quote);
}, delayMs);
};
const failOrRetry = (info: ChatErrorInfo) => {
settled = true;
if (shouldAutoRetry(info, attemptIndex)) {
const delayMs = autoRetryDelayMs(attemptIndex);
updateAsst({
status: 'auto_retrying',
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
});
scheduleRetry(delayMs);
return;
}
// 动作完全由服务端 error_info.actions 声明(P2 放开 continue/restart;闲聊路径天然仅 retry)
updateAsst({
status: 'failed',
errorInfo: info,
livePhaseLabel: undefined,
});
setIsThinking(false);
};
updateAsst({
status: 'pending',
livePhaseLabel: attemptIndex > 0 ? '正在重试…' : '助手正在处理…',
content: '',
errorInfo: undefined,
});
setIsThinking(true);
streamChat(
{
session_id: activeSessionId,
user_id: currentUser.id,
project_id: currentProj?.projectId || 1,
user_name: currentUser.name,
user_role: currentDocRole,
message: userText,
quote: quote,
},
{
onEvent: (ev) => {
if (controller.signal.aborted || settled) return;
switch (ev.type) {
case 'phase':
case 'status': {
const label = ev.phase?.message || ev.status?.message;
updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' });
break;
}
case 'delta': {
accumulated += ev.delta || '';
updateAsst({ status: 'streaming_status', content: accumulated });
break;
}
case 'error': {
failOrRetry(mapErrorInfo(ev));
break;
}
case 'done': {
settled = true;
const serverMsgId = ev.message_id || (ev as any).messageId || ev.done?.message_id;
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,
});
setIsThinking(false);
break;
}
default:
break;
}
},
},
controller.signal
).catch((_err) => {
if (settled) return;
if (controller.signal.aborted) {
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
setIsThinking(false);
return;
}
// fetch/网络层抛出:合成瞬态错误,走与后端 error 事件一致的自动重试路径
failOrRetry(buildNetworkErrorInfo());
});
};
// P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区
const runTaskAttempt = (
task: TaskRunContext,
asstId: string,
mode: 'run' | 'resume' | 'restart',
attemptIndex: number,
opts?: { phaseEvents?: PhaseEvent[]; workDir?: string }
) => {
// 同一时刻只允许一个活跃流:新尝试前中止上一个 (若存在)
streamAbortRef.current?.abort();
const controller = new AbortController();
streamAbortRef.current = 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 });
setIsThinking(false);
return;
}
runTaskAttempt(task, asstId, mode, attemptIndex + 1, opts);
}, delayMs);
};
const failOrRetry = (info: ChatErrorInfo) => {
settled = true;
if (shouldAutoRetry(info, attemptIndex)) {
const delayMs = autoRetryDelayMs(attemptIndex);
patchAsst({
status: 'auto_retrying',
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
});
scheduleRetry(delayMs);
return;
}
// 动作由服务端 error_info.actions 声明(resumable → 继续/重新开始,transient → 重试)
patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined });
setIsThinking(false);
};
// 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条
patchAsst({
status: 'pending',
livePhaseLabel:
mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…',
errorInfo: undefined,
...(mode === 'resume' ? {} : { content: '', phaseEvents: [], taskWorkspaceId: undefined }),
});
setIsThinking(true);
const body =
mode === 'resume' && opts?.workDir
? buildResumeRequest(task, opts.workDir)
: buildTaskRequest(task);
streamTask(
body,
{
onEvent: (ev) => {
if (controller.signal.aborted || settled) return;
switch (ev.type) {
case 'phase': {
// live PhaseEventStrip:由真实 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 'error': {
failOrRetry(mapErrorInfo(ev));
break;
}
case 'done': {
settled = true;
patchAsst({
status: 'completed',
livePhaseLabel: undefined,
taskWorkspaceId: ev.done?.workspace_id,
});
setIsThinking(false);
break;
}
default:
break;
}
},
},
controller.signal
).catch((_err) => {
if (settled) return;
if (controller.signal.aborted) {
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
setIsThinking(false);
return;
}
// fetch/网络层抛出:合成瞬态错误,走与后端 error 事件一致的自动重试路径
failOrRetry(buildNetworkErrorInfo());
});
};
// 结构化错误 / 已停止会话上的用户手动恢复动作 (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') {
runTaskAttempt(target.taskRequest, 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);
};
// 用户主动停止:中止当前流式请求,级联取消后续自动重试
const handleStopStreaming = () => {
streamAbortRef.current?.abort();
};
// 发送消息与对话驱动双轨逻辑
const handleSendMessage = (
text: string,
attachments?: { name: string; size: string; type: string }[],
quotes?: QuotedMessage[]
) => {
const trimmed = text.trim();
const userMsg: ChatMessage = {
id: 'msg-' + Date.now(),
sessionId: activeSessionId,
role: 'user',
authorName: currentUser.name,
content: text,
timestamp: new Date().toTimeString().substring(0, 5),
attachments,
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;
}
}
// 递减消息列表中尚未完结的 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' && currentDocRole === 'reviewer') ||
(docAST.lifecycleState === 'approving' && currentDocRole === 'approver'));
const matchedTemplate = currentProjectDeliverables.find((d) => {
if (!d.isTemplateOnly) return false;
if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) {
return d.taskKey === 'intake' || d.taskKey === 'user_requirements';
}
if (text.includes('调研') || text.includes('竞品')) {
return d.taskKey === 'research';
}
if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) {
return d.taskKey === 'feasibility_reg' || d.taskKey === 'feasibility_dev';
}
if (text.includes('设计') || text.includes('规格') || text.includes('架构')) {
return d.taskKey === 'prelim_design';
}
if (text.includes('报价') || text.includes('工时') || text.includes('预算')) {
return d.taskKey === 'cdo_quote';
}
return false;
});
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
if (matchedTemplate) {
const asstId = 'ast-' + Date.now();
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
const task: TaskRunContext = {
taskKey: matchedTemplate.taskKey,
projectName: currentProj?.title || '医疗器械研发演示项目',
customerName: '北京水木东方',
deviceType: currentProj?.deviceType || '医疗器械',
userId: currentUser.id,
projectId: currentProj?.projectId || 1,
};
const placeholder: ChatMessage = {
id: asstId,
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
content: '',
timestamp: new Date().toTimeString().substring(0, 5),
status: 'pending',
livePhaseLabel: '任务执行中…',
taskRequest: task,
};
setMessages((prev) => [...prev, placeholder]);
runTaskAttempt(task, asstId, 'run', 0);
return;
}
if (!isMockShortcut) {
// 真实后端 SSE 路径:立即挂起「等待中」占位气泡,无人为延迟,随后由 runStreamAttempt 驱动状态机
// (pending → streaming_status → completed/failed/auto_retrying/cancelled),严禁静默兜底话术
const asstId = 'ast-' + Date.now();
const placeholder: ChatMessage = {
id: asstId,
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, quotes && quotes.length > 0 ? quotes[0] : undefined);
return;
}
setIsThinking(true);
setTimeout(() => {
setIsThinking(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: sessions.find((s) => s.sessionId === activeSessionId)?.projectId || 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') {
setPendingProposal({
changes: '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。',
impact: '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。',
risk: '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。',
});
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🛡️ 文档认领保护机制拦截',
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
}
if (text.includes('通过') || text.includes('同意')) {
if (docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') {
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' && currentDocRole === 'approver') {
handleApprove();
return;
}
}
// P2:模板填报演示已由真实 task stream 链路替代(见上方 matchedTemplate 分支)
}, 1000);
};
// 编制人接受变更提议
const handleAcceptProposal = () => {
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: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
// 块操作
const handleSaveBlockEdit = (updated: DocumentBlock) => {
const children = docAST.rootBlock.children || [];
const idx = children.findIndex((c) => c.id === updated.id);
if (idx !== -1) {
children[idx] = updated;
recomputeNumbering(docAST.rootBlock);
setDocAST({ ...docAST });
}
setEditingBlock(null);
};
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) 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 = () => {
docAST.isClaimed = true;
setDocAST({ ...docAST });
setDeliverables((prev) =>
prev.map((d) => (d.docId === activeDocId ? { ...d, isClaimed: true } : d))
);
};
const handleSubmitReview = () => {
docAST.lifecycleState = 'reviewing';
setDocAST({ ...docAST });
setDeliverables((prev) =>
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'reviewing' } : d))
);
};
const handlePassReview = () => {
docAST.lifecycleState = 'approving';
setDocAST({ ...docAST });
setDeliverables((prev) =>
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approving' } : d))
);
};
const handleRejectReview = (reason: string) => {
docAST.lifecycleState = 'drafting';
setDocAST({ ...docAST });
setDeliverables((prev) =>
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'drafting' } : d))
);
setMessages((prev) => [
...prev,
{
id: 'rej-' + Date.now(),
sessionId: activeSessionId,
role: 'system',
content: `⚠️ 李主管(审核负责人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
const handleApprove = () => {
docAST.lifecycleState = 'approved';
setDocAST({ ...docAST });
setDeliverables((prev) =>
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approved' } : d))
);
setMessages((prev) => [
...prev,
{
id: 'appr-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🏆 终审批准生效',
content: `🎉 祝贺!王总监(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
timestamp: new Date().toTimeString().substring(0, 5),
recommendedCases: [
{
docId: docAST.docId,
taskKey: docAST.taskKey,
artifactTitle: docAST.title,
sectionTitle: '终审已生效标杆案例',
similarityScore: 1.0,
keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'],
summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
},
],
},
]);
};
// 导出 DOCX (Word 渲染器)
const handleExportDocx = async () => {
try {
await exportDocumentToDocx(docAST);
} catch (err) {
console.error('导出 DOCX 失败:', err);
}
};
// 导出单条智能体回复 DOCX (轻量专属 Word 导出)
const handleExportReplyDocx = async (message: ChatMessage) => {
try {
await downloadDocxReply(message.content || '', undefined, {
createdAt: message.createdAt || message.timestamp,
});
} catch (err: any) {
console.error('导出单条回复 DOCX 失败:', err);
}
};
// 导出 XLSX (Excel 渲染器)
const handleExportXlsx = async () => {
try {
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);
if (newSession.env) setSystemEnv(newSession.env);
const match = allUsers.find((u) => u.id === newSession.user.id);
if (match) {
setCurrentUser(match);
} else {
setCurrentUser(newSession.user);
}
}}
systemConfig={systemAuthConfig}
env={systemEnv}
/>
);
}
return (
<>
<AppShell
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={(sessId) => {
// 切换项目时清理旧会话的残留锚点 hash,防止回弹
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
setActiveSessionId(sessId);
}}
onNewSession={() => {
const newSessId = 'sess-' + Date.now();
const newProjId = Date.now();
const newProjectTitle = '新建医疗器械研发项目';
const newDeviceType = '通用医疗器械';
const newSess: ProjectSession = {
sessionId: newSessId,
projectId: newProjId,
title: newProjectTitle,
deviceType: newDeviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 1,
updatedAt: '刚刚',
};
const initMsg: ChatMessage = {
id: 'm-init-' + Date.now(),
sessionId: newSessId,
role: 'system',
content: '水木东方协同参谋小队已就位,当前新项目进入【V0.1 需求摸底与产品定义】阶段。全套研发工作流与阶段文档模板已克隆就绪,请在右侧查阅或在此输入您的研发构想。',
timestamp: new Date().toTimeString().substring(0, 5),
};
// 即时克隆全生命周期 7 份受控模板副本
const { deliverables: clonedDocs, docASTMap: clonedAsts } = createProjectTemplateDeliverables({
projectId: newProjId,
projectName: newProjectTitle,
deviceType: newDeviceType,
creatorId: currentUser.id,
creatorName: currentUser.name,
});
setSessions((prev) => [newSess, ...prev]);
setMessages((prev) => [...prev, initMsg]);
setDeliverables((prev) => [...prev, ...clonedDocs]);
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
setActiveSessionId(newSessId);
setActiveDocId(clonedDocs[0].docId);
setActiveCategory('workflow');
}}
canCreateProject={canCreateProject}
currentUser={currentUser}
currentRole={currentDocRole}
currentUserName={currentUser.name}
onChangeRole={(_, __) => {}}
onOpenRightsModal={() => setRightsModalOpen(true)}
onOpenUserSettings={() => setUserSettingsOpen(true)}
onOpenAdminSettings={() => setAdminSettingsOpen(true)}
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 && (
<ChatExportModal
isOpen={isExportOpen}
onClose={() => setIsExportOpen(false)}
sessionId={activeSessionId}
dayStack={chatWindow.displayDayStack}
/>
)}
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<MessageStream
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}
/>
<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' }}>
<ProposalConfirmCard
proposedChanges={pendingProposal.changes}
impactAnalysis={pendingProposal.impact}
potentialRisk={pendingProposal.risk}
onAccept={handleAcceptProposal}
onReject={() => setPendingProposal(null)}
/>
</div>
)}
<InputArea
onSendMessage={handleSendMessage}
isThinking={isThinking}
isStreaming={isStreamingActive}
onStopStreaming={handleStopStreaming}
quotes={activeQuotes}
onRemoveQuote={handleRemoveQuote}
/>
</div>
}
docContent={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', position: 'relative' }}>
{/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */}
<SlimTabBar
deliverables={visibleDeliverables}
activeDocId={activeDocId}
activeCategory={activeCategory}
onSelectCategory={setActiveCategory}
onSelectDoc={handleSelectDoc}
onToggleDeckDrawer={() => setDeckDrawerOpen(!deckDrawerOpen)}
onExportDocx={handleExportDocx}
onExportXlsx={handleExportXlsx}
onExportMarkdown={handleExportMarkdown}
/>
{/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */}
<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
ast={docAST}
currentRole={currentDocRole}
onEditBlock={setEditingBlock}
onInsertBlock={handleInsertBlock}
onMoveBlock={handleMoveBlock}
onDeleteBlock={handleDeleteBlock}
onOpenComments={setCommentingBlock}
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)}
/>
) : (
<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={handleRejectReview}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
/>
)}
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
{editingBlock && (
<BlockEditorModal
block={editingBlock}
onSave={handleSaveBlockEdit}
onClose={() => setEditingBlock(null)}
/>
)}
{/* 审核批注侧栏 */}
{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}
allUsers={allUsers}
canAssignLead={canAssignLead}
allowDevSwitch={systemEnv === 'dev'}
onSwitchUser={(u) => {
const fresh = allUsers.find((x) => x.id === u.id) || u;
setCurrentUser(fresh);
}}
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}
/>
{/* 系统管理员独占治理控制台 (仅 role-admin 可入) */}
<AdminSettingsModal
isOpen={adminSettingsOpen}
onClose={() => setAdminSettingsOpen(false)}
currentUser={currentUser}
roles={roles}
onSaveRoles={(newRoles) => setRoles(newRoles)}
allUsers={allUsers}
onUpdateUserRole={(userId, roleId) => {
const targetRole = roles.find((r) => r.id === roleId);
setAllUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, roleId, position: targetRole?.name || u.position } : u))
);
if (currentUser.id === userId) {
setCurrentUser((prev) => ({ ...prev, roleId, position: targetRole?.name || prev.position }));
}
}}
systemConfig={systemAuthConfig}
onUpdateSystemConfig={(cfg) => setSystemAuthConfig((prev) => ({ ...prev, ...cfg }))}
onRefreshUsers={() => {
authClient
.getAdminUsers()
.then((users) => {
setAllUsers(users);
})
.catch(() => {});
}}
env={systemEnv}
/>
{/* 研发调试快捷身份切换浮窗 (仅在 DEV 环境生效,生产与测试环境彻底剥离) */}
<DevRoleSwitcher
env={systemEnv}
currentUser={currentUser}
allUsers={allUsers}
onSwitchUser={(newUser) => {
const fresh = allUsers.find((u) => u.id === newUser.id) || newUser;
setCurrentUser(fresh);
}}
/>
{/* 单文档可见性覆盖弹窗 */}
<DocVisibilityModal
isOpen={docVisibilityModalOpen}
onClose={() => setDocVisibilityModalOpen(false)}
doc={targetDocForVisibility}
roles={roles}
currentUserName={currentUser.name}
onSaveRule={handleSaveDocVisibilityRule}
/>
</>
);
}