fix(web): scope Task 5 App.tsx to SSE wiring only

- Rebased App.tsx on the last clean Task-4 parent (5aa8129) and re-applied
  only Task-5's SSE plumbing: streamAbortRef, isStreamingActive,
  buildNetworkErrorInfo, runStreamAttempt, handleErrorAction,
  handleStopStreaming, and the InputArea/MessageStream wiring.
- Removed the unscoped WIP that had been folded into b2cf863 (NeuroPulse
  demo data/session-privacy/docgov-lease/cardActionPolicy imports that do
  not exist in git, causing b2cf863 to fail to build in isolation).
- Real-backend path now appends the pending placeholder and calls
  runStreamAttempt immediately; the artificial setTimeout(600) is only
  used for the mock keyword-shortcut demo branches (unchanged from 5aa8129),
  gated by precomputed predicates so it never wraps the SSE call.
- Preserved 5aa8129's mock keyword shortcuts (生成文档/驳回/修改/通过等) verbatim.
- No silent success-template fallback re-introduced.
- P1 error actions remain filtered to retry-only; auto-retry transient
  attempts stay capped at 2 with 1s/2s backoff via AbortController.
- Bloated WIP App.tsx backed up to .superpowers/sdd/App.tsx.wip-backup
  for later use by whichever task owns NeuroPulse/session-privacy/docgov
  lease work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:20 +00:00
committed by weichen
co-authored by Cursor
parent 0cb664d540
commit d03d9396e1
+151 -425
View File
@@ -17,7 +17,6 @@ import { LoginView } from './components/auth/LoginView';
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
import { DevRoleSwitcher } from './components/dev/DevRoleSwitcher';
import { authClient } from './api/auth';
import { saveDocumentContent, mapLeaseToAstEditLease } from './api/docgov';
import { streamChat, mapErrorInfo } from './api/chatStream';
import { shouldAutoRetry, autoRetryDelayMs, fallbackErrorClass } from './api/chatRetry';
import { LoginResult, SystemAuthConfig } from './types/auth';
@@ -41,17 +40,11 @@ import {
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,
extractLatestRejectReasonFromAst,
isAffirmativeResponse,
findLastActiveCard,
decrementActiveCardsTtl,
@@ -60,11 +53,6 @@ import {
resolveCardInMessages,
cancelCardInMessages,
} from './engine/cardActionEngine';
import {
classifyRejectUtterance,
resolveDocGovCard,
canConfirmReject,
} from './engine/cardActionPolicy';
import { createProjectTemplateDeliverables } from './engine/templateFactory';
import {
loadSessions,
@@ -79,11 +67,6 @@ import {
saveRoles,
loadUsers,
saveUsers,
buildPrivateSessionId,
filterSessionsForUser,
resolveActiveSessionForUser,
ensurePrivateSessionsForUser,
DEFAULT_MESSAGES,
} from './data/storage';
export const INITIAL_DELIVERABLES: DeliverableItem[] = [
@@ -232,98 +215,11 @@ const P2_INITIAL = createProjectTemplateDeliverables({
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,
];
const ALL_MOCK_DOCUMENTS = {
...MOCK_DOCUMENTS,
...NEUROPULSE_DOCUMENTS,
};
export const MOCK_USERS: UserProfile[] = [
{
id: 1,
@@ -339,11 +235,6 @@ export const MOCK_USERS: UserProfile[] = [
'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'] || {}),
},
},
{
@@ -360,11 +251,6 @@ export const MOCK_USERS: UserProfile[] = [
'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'] || {}),
},
},
{
@@ -380,11 +266,6 @@ export const MOCK_USERS: UserProfile[] = [
'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'] || {}),
},
},
{
@@ -412,11 +293,10 @@ export const MOCK_USERS: UserProfile[] = [
];
export default function App() {
// 会话与用户状态 (支持 localStorage 刷新持久化;每人独立私聊)
// 会话与用户状态 (支持 localStorage 刷新持久化)
const [sessions, setSessions] = useState<ProjectSession[]>(() => loadSessions());
const [activeSessionId, setActiveSessionId] = useState<string>(() =>
loadActiveSessionId(buildPrivateSessionId('cusa', 1))
);
const [activeSessionId, setActiveSessionId] = useState<string>(() => loadActiveSessionId('sess-001'));
// 自动持久化 sessions 与 activeSessionId
useEffect(() => {
saveSessions(sessions);
@@ -442,46 +322,6 @@ export default function App() {
// 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责)
const [currentUser, setCurrentUser] = useState<UserProfile>(() => allUsers[0] || MOCK_USERS[0]);
/** 切换登录用户时,强制切到该用户私聊会话,严禁看到他人对话 */
const activateUserPrivateChat = (user: UserProfile, preferredProjectId?: number) => {
setSessions((prev) => {
const ensured = ensurePrivateSessionsForUser(prev, user.id);
const counts: Record<string, number> = {};
for (const m of DEFAULT_MESSAGES) {
counts[m.sessionId] = (counts[m.sessionId] || 0) + 1;
}
for (const s of ensured) {
counts[s.sessionId] = Math.max(counts[s.sessionId] || 0, s.messageCount || 0);
}
const nextActive = resolveActiveSessionForUser(
ensured,
user.id,
preferredProjectId,
activeSessionId,
counts
);
if (nextActive) {
setActiveSessionId(nextActive);
}
return ensured;
});
setCurrentUser(user);
};
/** 登录账号可能与工作台 MOCK id 不一致:优先按姓名对齐到工作台用户 */
const resolveWorkbenchUser = (incoming: UserProfile): UserProfile => {
const byName = allUsers.find((u) => u.name === incoming.name);
if (byName) return byName;
const byId = allUsers.find((u) => u.id === incoming.id);
if (byId) return byId;
return incoming;
};
/** 左侧项目列表:仅展示当前登录用户自己的私聊会话 */
const visibleSessions = useMemo(
() => filterSessionsForUser(sessions, currentUser.id),
[sessions, currentUser.id]
);
// 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类,支持持久化)
const [deliverables, setDeliverables] = useState<DeliverableItem[]>(() =>
loadDeliverables(ALL_INITIAL_DELIVERABLES)
@@ -493,7 +333,7 @@ export default function App() {
}, [deliverables]);
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
const [activeDocId, setActiveDocId] = useState('doc-neuropulse-feasibility_reg');
const [activeDocId, setActiveDocId] = useState('doc-001');
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
// 认证与会话状态
@@ -517,9 +357,12 @@ export default function App() {
if (saved) {
setSession(saved);
if (saved.env) setSystemEnv(saved.env);
activateUserPrivateChat(resolveWorkbenchUser(saved.user));
} else {
activateUserPrivateChat(allUsers[0] || MOCK_USERS[0]);
const match = allUsers.find((u) => u.id === saved.user.id);
if (match) {
setCurrentUser(match);
} else {
setCurrentUser(saved.user);
}
}
}, []);
@@ -594,7 +437,7 @@ export default function App() {
// 文档缓存与当前 AST
const [docCache, setDocCache] = useState<Record<string, DocumentAST>>(() => {
const initialCache: Record<string, DocumentAST> = { ...P2_INITIAL.docASTMap };
Object.entries(ALL_MOCK_DOCUMENTS).forEach(([id, meta]) => {
Object.entries(MOCK_DOCUMENTS).forEach(([id, meta]) => {
initialCache[id] = markdownToBlockAST(meta.markdown, {
docId: meta.docId,
title: meta.title,
@@ -646,8 +489,8 @@ export default function App() {
if (targetDoc) {
setActiveCategory(targetDoc.category);
}
if (!docCache[id] && ALL_MOCK_DOCUMENTS[id]) {
const meta = ALL_MOCK_DOCUMENTS[id];
if (!docCache[id] && MOCK_DOCUMENTS[id]) {
const meta = MOCK_DOCUMENTS[id];
const parsed = markdownToBlockAST(meta.markdown, {
docId: meta.docId,
title: meta.title,
@@ -688,7 +531,7 @@ export default function App() {
id: 'ast-share-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🌐 一键共享至项目参考库',
content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`,
timestamp: new Date().toTimeString().substring(0, 5),
@@ -716,7 +559,7 @@ export default function App() {
id: 'ast-revoke-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🔒 撤回共享至个人私密',
content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`,
timestamp: new Date().toTimeString().substring(0, 5),
@@ -755,27 +598,6 @@ export default function App() {
saveMessages(messages);
}, [messages]);
// 防护:若 activeSession 不属于当前用户,或指向空壳会话而同项目另有历史,立即纠正
useEffect(() => {
const mine = filterSessionsForUser(sessions, currentUser.id);
if (mine.length === 0) return;
const counts: Record<string, number> = {};
for (const m of messages) {
counts[m.sessionId] = (counts[m.sessionId] || 0) + 1;
}
const preferred = sessions.find((s) => s.sessionId === activeSessionId)?.projectId;
const best = resolveActiveSessionForUser(
sessions,
currentUser.id,
preferred,
activeSessionId,
counts
);
if (best && best !== activeSessionId) {
setActiveSessionId(best);
}
}, [currentUser.id, sessions, activeSessionId, messages]);
// 基于当前激活的项目会话严格隔离对话记录
const currentSessionMessages = useMemo(() => {
return messages.filter((m) => m.sessionId === activeSessionId);
@@ -807,23 +629,6 @@ export default function App() {
payload?.reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。';
if (actionId === 'confirm_reject') {
// 二次鉴权:仅审核/批准可执行驳回
if (!canConfirmReject(currentDocRole)) {
setMessages((prev) => [
...prev,
{
id: 'ast-reject-denied-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
intentTag: '⚠️ 权限不符',
content: `您当前在该文档中的身份为「${currentDocRole === 'author' ? '编制负责人' : currentDocRole}」,无权确认驳回。若需了解驳回原因,请直接询问。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
// 1. 调用既有文档驳回逻辑 (将目标文档生命周期由 reviewing 回退为 drafting)
setDeliverables((prev) =>
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'drafting' } : d))
@@ -834,8 +639,8 @@ export default function App() {
let targetSectionName = '第3节';
setDocCache((prev) => {
let currentDoc = prev[targetDocId];
if (!currentDoc && ALL_MOCK_DOCUMENTS[targetDocId]) {
const meta = ALL_MOCK_DOCUMENTS[targetDocId];
if (!currentDoc && MOCK_DOCUMENTS[targetDocId]) {
const meta = MOCK_DOCUMENTS[targetDocId];
currentDoc = markdownToBlockAST(meta.markdown, {
docId: meta.docId,
title: meta.title,
@@ -916,41 +721,12 @@ export default function App() {
id: 'ast-cancel-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🚫 取消操作',
content: '已取消驳回操作,文档保持待审核状态。',
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} else if (actionId === 'ack_reject_reason') {
setMessages((prev) => [
...resolveCardInMessages(prev, cardId, '已阅驳回原因说明', currentUser.name),
{
id: 'ast-ack-reason-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
intentTag: '✅ 已阅',
content: '好的,已记录您已阅驳回原因。可随时在文档批注中继续查看整改要点。',
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} else if (actionId === 'start_revision') {
setMessages((prev) => [
...resolveCardInMessages(prev, cardId, '开始按驳回意见修订', currentUser.name),
{
id: 'ast-start-rev-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
intentTag: '✏️ 修订引导',
content: `请在右侧工作台按驳回批注逐项修订《${targetCard?.targetDocTitle || targetDocId}》。修订完成后可通过对话或工具栏重新提交送审。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
if (targetDocId) {
setActiveDocId(targetDocId);
}
} else if (actionId === 'undo') {
// 撤销驳回:恢复为待审核状态 (reviewing)
setDeliverables((prev) =>
@@ -975,7 +751,7 @@ export default function App() {
id: 'ast-undo-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '↩️ 撤回驳回',
content: '已在合法窗口期撤回驳回,文档已恢复为待审核状态。',
timestamp: new Date().toTimeString().substring(0, 5),
@@ -1006,7 +782,7 @@ export default function App() {
id: 'appr-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🏆 终审批准生效',
content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${targetCard?.targetDocTitle || targetDocId}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
timestamp: new Date().toTimeString().substring(0, 5),
@@ -1093,7 +869,7 @@ export default function App() {
{
session_id: activeSessionId,
user_id: currentUser.id,
project_id: currentProj?.projectId || 3,
project_id: currentProj?.projectId || 1,
user_name: currentUser.name,
user_role: currentDocRole,
message: userText,
@@ -1187,20 +963,73 @@ export default function App() {
// 递减消息列表中尚未完结的 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 || !!matchedTemplate;
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);
return;
}
setIsThinking(true);
setTimeout(() => {
setIsThinking(false);
// 1. 用户要求助手生成新文档/测算/推演/备忘 (默认自动归档至【我的私密生成】,保护隐私)
if (
text.includes('生成文档') ||
text.includes('起草文档') ||
text.includes('测算') ||
text.includes('推演') ||
text.includes('备忘') ||
text.includes('生成报告')
) {
if (isGenDocShortcut) {
const newDocId = `doc-private-${Date.now()}`;
const docTitle = text.includes('备忘')
? `${currentUser.name}专属:临床与商业推演备忘录`
@@ -1228,7 +1057,7 @@ export default function App() {
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 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,
@@ -1254,7 +1083,7 @@ export default function App() {
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🔒 个人私密文档已生成',
content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`,
timestamp: new Date().toTimeString().substring(0, 5),
@@ -1263,81 +1092,48 @@ export default function App() {
return;
}
// 2. 驳回相关:按角色 × 意图分流,禁止编制人误触审核驳回确认卡
if (text.includes('驳回') || text.includes('退回')) {
const utteranceIntent = classifyRejectUtterance(text);
const targetDocForReject =
utteranceIntent === 'EXECUTE_REJECT'
? docAST.lifecycleState === 'reviewing' || docAST.lifecycleState === 'approving'
? docAST
: (() => {
const meta = deliverables.find(
(d) =>
d.lifecycleState === 'reviewing' || d.lifecycleState === 'approving'
);
return meta ? docCache[meta.docId] || docAST : docAST;
})()
: docAST;
// 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;
const latestReason =
extractLatestRejectReasonFromAst(targetDocForReject) ||
extractLatestRejectReasonFromAst(docAST) ||
'缺少 YY 9706.102 电磁兼容专项与听神经保护方案,退回修改';
if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') {
const matchingBlock = findMatchingBlockForReview(targetReviewDoc);
const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock);
const matchingBlock = findMatchingBlockForReview(targetDocForReject);
const sectionLabel = matchingBlock?.numbering
? `${matchingBlock.numbering} ${matchingBlock.content.slice(0, 24)}`
: undefined;
const resolved = resolveDocGovCard({
role: currentDocRole,
lifecycleState: targetDocForReject.lifecycleState,
utteranceIntent,
docId: targetDocForReject.docId,
docTitle: targetDocForReject.title,
latestRejectReason:
utteranceIntent === 'EXECUTE_REJECT' &&
(currentDocRole === 'reviewer' || currentDocRole === 'approver')
? undefined // 执行路径由确认卡草案填充;若用户已写意见可后续从文本提取
: latestReason,
sectionLabel,
sectionId: matchingBlock?.id,
});
// 审核/批准执行路径:用真实确认卡工厂补全草案(含用户文本中的意见)
let card = resolved.card;
if (
(resolved.kind === 'reject_confirm_review' ||
resolved.kind === 'reject_confirm_final') &&
targetDocForReject
) {
const reasonFromText = text.includes('意见')
? text.replace(/^[\s\S]*意见[为是::\s]*/, '').trim()
: latestReason;
card = createRejectConfirmationCard(
{ docId: targetDocForReject.docId, title: targetDocForReject.title },
matchingBlock,
setMessages((prev) => [
...prev,
{
reason: reasonFromText || latestReason,
mode: resolved.kind === 'reject_confirm_final' ? 'final' : 'review',
}
);
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;
}
setMessages((prev) => [
...prev,
{
id: 'ast-reject-gate-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
intentTag: resolved.intentTag,
content: resolved.replyContent,
timestamp: new Date().toTimeString().substring(0, 5),
interactiveCard: card,
},
]);
return;
}
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
@@ -1354,9 +1150,9 @@ export default function App() {
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🛡️ 文档认领保护机制拦截',
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,项目助手不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
@@ -1373,7 +1169,7 @@ export default function App() {
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '📋 审核通过流转',
content: `已确认审核通过!《${docAST.title}》状态已流转至【待批准 (approving)】,已呈报王总监终审。`,
timestamp: new Date().toTimeString().substring(0, 5),
@@ -1387,41 +1183,21 @@ export default function App() {
}
}
// 3. 检查当前项目中是否存在待填报的模板副本
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;
});
// 3. 检查当前项目中是否存在待填报的模板副本 (复用函数顶部已计算的 matchedTemplate,避免重复扫描)
if (matchedTemplate) {
const existingAst = docCache[matchedTemplate.docId];
const updatedAst: DocumentAST = {
...existingAst,
lifecycleState: 'drafting',
isClaimed: true,
summary: `项目助手已依据您的指令“${text.slice(0, 40)}...”完成深度填报与法规对齐。`,
summary: `智能体参谋小队已依据您的指令“${text.slice(0, 40)}...”完成深度填报与法规对齐。`,
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
};
if (updatedAst.rootBlock && updatedAst.rootBlock.children) {
const firstP = updatedAst.rootBlock.children.find((b) => b.type === 'paragraph');
if (firstP) {
firstP.content += `\n\n> 🤖 **智能体填报生效**:项目助手已结合当前项目基线与权威法规知识库完成编制,骨架模板已平滑升级为受控草稿。`;
firstP.content += `\n\n> 🤖 **智能体填报生效**:参谋小队已结合当前项目基线与权威法规知识库完成编制,骨架模板已平滑升级为受控草稿。`;
}
}
@@ -1449,32 +1225,15 @@ export default function App() {
id: 'ast-fill-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🎯 模板填报升级',
content: `收到!项目助手已依据您的指令完成《${matchedTemplate.title}》的填报。\n\n该文档已成功从【📄 初始模板】平滑升格为【✍️ 编制中 (受控草稿)】,右侧工作台已为您实时呈现填报正文,请审阅。`,
content: `收到!参谋小队已依据您的指令完成《${matchedTemplate.title}》的填报。\n\n该文档已成功从【📄 初始模板】平滑升格为【✍️ 编制中 (受控草稿)】,右侧工作台已为您实时呈现填报正文,请审阅。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
return;
}
// 真实后端对话接口调用与智能体参谋协同:立即挂载「等待中」气泡,随后由 SSE 流式驱动状态机
// (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);
}, 600);
}, 1000);
};
// 编制人接受变更提议
@@ -1485,7 +1244,7 @@ export default function App() {
if (pBlock) {
pBlock.content += '\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
pBlock.metadata = {
lastModifiedBy: '项目助手 (经张工确认签署)',
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
};
setDocAST({ ...docAST });
@@ -1497,7 +1256,7 @@ export default function App() {
id: 'ast-p-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '✅ 协同变更已生效',
content: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`,
timestamp: new Date().toTimeString().substring(0, 5),
@@ -1505,44 +1264,14 @@ export default function App() {
]);
};
// 块操作:本地更新 + 经服务端内容 API 落盘(走编辑租约互斥)
const handleSaveBlockEdit = async (updated: DocumentBlock) => {
const leaseActive =
!!docAST.editLease?.holderType &&
(!docAST.editLease.expiresAt || new Date(docAST.editLease.expiresAt).getTime() > Date.now());
if (leaseActive) {
window.alert('文档正在由小队或其他用户编辑中,暂不可保存修改。');
setEditingBlock(null);
return;
}
// 块操作
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);
const nextAst = { ...docAST };
setDocAST(nextAst);
try {
const markdown = blockASTToMarkdown(nextAst);
const res = await saveDocumentContent({
doc_id: nextAst.docId,
user_id: currentUser.id,
user_name: currentUser.name,
user_role: currentDocRole || 'author',
markdown_body: markdown,
expected_sha256: nextAst.contentSha256,
});
setDocAST((prev) => ({
...prev,
contentSha256: res.content_sha256,
editLease: res.doc ? mapLeaseToAstEditLease(res.doc) : prev.editLease,
}));
} catch (err) {
console.warn('saveDocumentContent failed (local edit kept):', err);
window.alert(err instanceof Error ? err.message : '保存到服务端失败');
}
setDocAST({ ...docAST });
}
setEditingBlock(null);
};
@@ -1583,16 +1312,9 @@ export default function App() {
setDocAST({ ...docAST });
};
// 批注操作(编辑租约占用期间拒绝)
// 批注操作
const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => {
if (!commentingBlock) return;
const leaseActive =
!!docAST.editLease?.holderType &&
(!docAST.editLease.expiresAt || new Date(docAST.editLease.expiresAt).getTime() > Date.now());
if (leaseActive) {
window.alert('文档正在由小队或其他用户编辑中,暂不可批注。');
return;
}
const newCmt: BlockComment = {
...comment,
id: 'cmt_' + Math.random().toString(36).substring(2, 8),
@@ -1659,7 +1381,7 @@ export default function App() {
id: 'appr-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
authorName: '水木东方协同参谋',
intentTag: '🏆 终审批准生效',
content: `🎉 祝贺!王总监(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
timestamp: new Date().toTimeString().substring(0, 5),
@@ -1726,7 +1448,12 @@ export default function App() {
onLoginSuccess={(newSession) => {
setSession(newSession);
if (newSession.env) setSystemEnv(newSession.env);
activateUserPrivateChat(resolveWorkbenchUser(newSession.user));
const match = allUsers.find((u) => u.id === newSession.user.id);
if (match) {
setCurrentUser(match);
} else {
setCurrentUser(newSession.user);
}
}}
systemConfig={systemAuthConfig}
env={systemEnv}
@@ -1737,18 +1464,17 @@ export default function App() {
return (
<>
<AppShell
sessions={visibleSessions}
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={setActiveSessionId}
onNewSession={() => {
const newSessId = 'sess-' + Date.now();
const newProjId = Date.now();
const newSessId = buildPrivateSessionId(`p${newProjId}`, currentUser.id);
const newProjectTitle = '新建医疗器械研发项目';
const newDeviceType = '通用医疗器械';
const newSess: ProjectSession = {
sessionId: newSessId,
userId: currentUser.id,
projectId: newProjId,
title: newProjectTitle,
deviceType: newDeviceType,
@@ -1762,7 +1488,7 @@ export default function App() {
id: 'm-init-' + Date.now(),
sessionId: newSessId,
role: 'system',
content: `项目助手已就位。本窗口为【${currentUser.name}】与智能体的专属私聊,其他成员不可见。当前新项目进入【V0.1 需求摸底与产品定义】阶段。`,
content: '水木东方协同参谋小队已就位,当前新项目进入【V0.1 需求摸底与产品定义】阶段。全套研发工作流与阶段文档模板已克隆就绪,请在右侧查阅或在此输入您的研发构想。',
timestamp: new Date().toTimeString().substring(0, 5),
};
@@ -1923,8 +1649,8 @@ export default function App() {
canAssignLead={canAssignLead}
allowDevSwitch={systemEnv === 'dev'}
onSwitchUser={(u) => {
const preferred = sessions.find((s) => s.sessionId === activeSessionId)?.projectId;
activateUserPrivateChat(resolveWorkbenchUser(u), preferred);
const fresh = allUsers.find((x) => x.id === u.id) || u;
setCurrentUser(fresh);
}}
onSelectDoc={handleSelectDoc}
onRoleChangeForDoc={handleRoleChangeForDoc}
@@ -1993,8 +1719,8 @@ export default function App() {
currentUser={currentUser}
allUsers={allUsers}
onSwitchUser={(newUser) => {
const preferred = sessions.find((s) => s.sessionId === activeSessionId)?.projectId;
activateUserPrivateChat(resolveWorkbenchUser(newUser), preferred);
const fresh = allUsers.find((u) => u.id === newUser.id) || newUser;
setCurrentUser(fresh);
}}
/>