From b2cf8637a3735986e2ae21f4eb00d7e908dfad7d Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 9 Sep 2026 16:47:52 +0800 Subject: [PATCH] feat(web): drive chat bubbles from SSE with auto-retry and stop Co-authored-by: Cursor --- web/src/App.tsx | 644 ++++++++++++++++++---- web/src/components/chat/InputArea.tsx | 4 +- web/src/components/chat/MessageStream.tsx | 3 + 3 files changed, 548 insertions(+), 103 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1414953f..94e83c19 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react'; +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'; @@ -17,6 +17,9 @@ 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'; import { markdownToBlockAST } from './engine/markdownToBlock'; import { blockASTToMarkdown } from './engine/blockToMarkdown'; @@ -25,6 +28,7 @@ import { DocumentAST, DocumentBlock, BlockComment } from './types/block'; import { ProjectSession, ChatMessage, + ChatErrorInfo, DeliverableItem, DeliverableCategory, InteractiveCardData, @@ -37,11 +41,17 @@ 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, @@ -50,6 +60,11 @@ import { resolveCardInMessages, cancelCardInMessages, } from './engine/cardActionEngine'; +import { + classifyRejectUtterance, + resolveDocGovCard, + canConfirmReject, +} from './engine/cardActionPolicy'; import { createProjectTemplateDeliverables } from './engine/templateFactory'; import { loadSessions, @@ -64,6 +79,11 @@ import { saveRoles, loadUsers, saveUsers, + buildPrivateSessionId, + filterSessionsForUser, + resolveActiveSessionForUser, + ensurePrivateSessionsForUser, + DEFAULT_MESSAGES, } from './data/storage'; export const INITIAL_DELIVERABLES: DeliverableItem[] = [ @@ -212,11 +232,98 @@ 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, @@ -232,6 +339,11 @@ 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'] || {}), }, }, { @@ -248,6 +360,11 @@ 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'] || {}), }, }, { @@ -263,6 +380,11 @@ 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'] || {}), }, }, { @@ -290,10 +412,11 @@ export const MOCK_USERS: UserProfile[] = [ ]; export default function App() { - // 会话与用户状态 (支持 localStorage 刷新持久化) + // 会话与用户状态 (支持 localStorage 刷新持久化;每人独立私聊) const [sessions, setSessions] = useState(() => loadSessions()); - const [activeSessionId, setActiveSessionId] = useState(() => loadActiveSessionId('sess-001')); - + const [activeSessionId, setActiveSessionId] = useState(() => + loadActiveSessionId(buildPrivateSessionId('cusa', 1)) + ); // 自动持久化 sessions 与 activeSessionId useEffect(() => { saveSessions(sessions); @@ -319,6 +442,46 @@ export default function App() { // 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责) const [currentUser, setCurrentUser] = useState(() => allUsers[0] || MOCK_USERS[0]); + /** 切换登录用户时,强制切到该用户私聊会话,严禁看到他人对话 */ + const activateUserPrivateChat = (user: UserProfile, preferredProjectId?: number) => { + setSessions((prev) => { + const ensured = ensurePrivateSessionsForUser(prev, user.id); + const counts: Record = {}; + 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(() => loadDeliverables(ALL_INITIAL_DELIVERABLES) @@ -330,7 +493,7 @@ export default function App() { }, [deliverables]); const [activeCategory, setActiveCategory] = useState('workflow'); - const [activeDocId, setActiveDocId] = useState('doc-001'); + const [activeDocId, setActiveDocId] = useState('doc-neuropulse-feasibility_reg'); const [deckDrawerOpen, setDeckDrawerOpen] = useState(false); // 认证与会话状态 @@ -354,12 +517,9 @@ export default function App() { 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); - } + activateUserPrivateChat(resolveWorkbenchUser(saved.user)); + } else { + activateUserPrivateChat(allUsers[0] || MOCK_USERS[0]); } }, []); @@ -434,7 +594,7 @@ export default function App() { // 文档缓存与当前 AST const [docCache, setDocCache] = useState>(() => { const initialCache: Record = { ...P2_INITIAL.docASTMap }; - Object.entries(MOCK_DOCUMENTS).forEach(([id, meta]) => { + Object.entries(ALL_MOCK_DOCUMENTS).forEach(([id, meta]) => { initialCache[id] = markdownToBlockAST(meta.markdown, { docId: meta.docId, title: meta.title, @@ -486,8 +646,8 @@ export default function App() { if (targetDoc) { setActiveCategory(targetDoc.category); } - if (!docCache[id] && MOCK_DOCUMENTS[id]) { - const meta = MOCK_DOCUMENTS[id]; + if (!docCache[id] && ALL_MOCK_DOCUMENTS[id]) { + const meta = ALL_MOCK_DOCUMENTS[id]; const parsed = markdownToBlockAST(meta.markdown, { docId: meta.docId, title: meta.title, @@ -528,7 +688,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), @@ -556,7 +716,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), @@ -587,17 +747,49 @@ export default function App() { // 消息流与思考脉冲 (支持 localStorage 持久化与项目会话严格隔离) const [isThinking, setIsThinking] = useState(false); const [messages, setMessages] = useState(() => loadMessages()); + // 真实后端 SSE 流式对话:当前活跃请求的中止句柄 (支持停止与自动重试取消) + const streamAbortRef = useRef(null); // 自动持久化消息流 useEffect(() => { saveMessages(messages); }, [messages]); + // 防护:若 activeSession 不属于当前用户,或指向空壳会话而同项目另有历史,立即纠正 + useEffect(() => { + const mine = filterSessionsForUser(sessions, currentUser.id); + if (mine.length === 0) return; + const counts: Record = {}; + 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); }, [messages, activeSessionId]); + // 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 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) => { // 查找对应的卡片 @@ -615,6 +807,23 @@ 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)) @@ -625,8 +834,8 @@ export default function App() { let targetSectionName = '第3节'; setDocCache((prev) => { let currentDoc = prev[targetDocId]; - if (!currentDoc && MOCK_DOCUMENTS[targetDocId]) { - const meta = MOCK_DOCUMENTS[targetDocId]; + if (!currentDoc && ALL_MOCK_DOCUMENTS[targetDocId]) { + const meta = ALL_MOCK_DOCUMENTS[targetDocId]; currentDoc = markdownToBlockAST(meta.markdown, { docId: meta.docId, title: meta.title, @@ -707,12 +916,41 @@ 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) => @@ -737,7 +975,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), @@ -768,7 +1006,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), @@ -788,6 +1026,138 @@ export default function App() { } }; + // 合成网络级异常的结构化错误信息 (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) => { + // 同一时刻只允许一个活跃流:新尝试前中止上一个 (若存在) + 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) => { + 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); + }, 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; + } + // P1:无论后端返回哪些动作,展示层仅保留「重试」 + updateAsst({ + status: 'failed', + errorInfo: { ...info, actions: info.actions.filter((a) => a === 'retry') }, + 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 || 3, + user_name: currentUser.name, + user_role: currentDocRole, + message: userText, + }, + { + 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; + updateAsst({ status: 'completed', livePhaseLabel: undefined }); + 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()); + }); + }; + + // 结构化错误 / 已停止会话上的用户手动恢复动作 (P1 仅支持 retry,continue/restart 留待 P2) + const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => { + if (action !== 'retry') return; + const target = messages.find((m) => m.id === messageId); + 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 }[]) => { const trimmed = text.trim(); @@ -858,7 +1228,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, @@ -884,7 +1254,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), @@ -893,48 +1263,81 @@ export default function App() { return; } - // 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物) + // 2. 驳回相关:按角色 × 意图分流,禁止编制人误触审核驳回确认卡 if (text.includes('驳回') || text.includes('退回')) { - const targetReviewDoc = - docAST.lifecycleState === 'reviewing' - ? docAST - : deliverables.find((d) => d.lifecycleState === 'reviewing') - ? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST) - : null; + 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; - if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') { - const matchingBlock = findMatchingBlockForReview(targetReviewDoc); - const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock); + const latestReason = + extractLatestRejectReasonFromAst(targetDocForReject) || + extractLatestRejectReasonFromAst(docAST) || + '缺少 YY 9706.102 电磁兼容专项与听神经保护方案,退回修改'; - setMessages((prev) => [ - ...prev, + 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, { - 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; + reason: reasonFromText || latestReason, + mode: resolved.kind === 'reject_confirm_final' ? 'final' : 'review', + } + ); } + + 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('调整')) { @@ -951,9 +1354,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), }, ]); @@ -970,7 +1373,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), @@ -1011,14 +1414,14 @@ export default function App() { ...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> 🤖 **智能体填报生效**:项目助手已结合当前项目基线与权威法规知识库完成编制,骨架模板已平滑升级为受控草稿。`; } } @@ -1046,28 +1449,32 @@ 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; } - setMessages((prev) => [ - ...prev, - { - id: 'ast-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '💡 协同参谋回复', - content: `收到您的要求:“${text}”。参谋小队已分析当前处于【${docAST.lifecycleState}】生命周期,右侧已实时同步最新受控文档结构。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - }, 1000); + // 真实后端对话接口调用与智能体参谋协同:立即挂载「等待中」气泡,随后由 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); }; // 编制人接受变更提议 @@ -1078,7 +1485,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 }); @@ -1090,7 +1497,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), @@ -1098,14 +1505,44 @@ export default function App() { ]); }; - // 块操作 - const handleSaveBlockEdit = (updated: DocumentBlock) => { + // 块操作:本地更新 + 经服务端内容 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 children = docAST.rootBlock.children || []; const idx = children.findIndex((c) => c.id === updated.id); if (idx !== -1) { children[idx] = updated; recomputeNumbering(docAST.rootBlock); - setDocAST({ ...docAST }); + 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 : '保存到服务端失败'); + } } setEditingBlock(null); }; @@ -1146,9 +1583,16 @@ export default function App() { setDocAST({ ...docAST }); }; - // 批注操作 + // 批注操作(编辑租约占用期间拒绝) const handleAddComment = (comment: Omit) => { 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), @@ -1215,7 +1659,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), @@ -1282,12 +1726,7 @@ export default function App() { 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); - } + activateUserPrivateChat(resolveWorkbenchUser(newSession.user)); }} systemConfig={systemAuthConfig} env={systemEnv} @@ -1298,17 +1737,18 @@ export default function App() { return ( <> { - 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, @@ -1322,7 +1762,7 @@ export default function App() { id: 'm-init-' + Date.now(), sessionId: newSessId, role: 'system', - content: '水木东方协同参谋小队已就位,当前新项目进入【V0.1 需求摸底与产品定义】阶段。全套研发工作流与阶段文档模板已克隆就绪,请在右侧查阅或在此输入您的研发构想。', + content: `项目助手已就位。本窗口为【${currentUser.name}】与智能体的专属私聊,其他成员不可见。当前新项目进入【V0.1 需求摸底与产品定义】阶段。`, timestamp: new Date().toTimeString().substring(0, 5), }; @@ -1357,6 +1797,7 @@ export default function App() { messages={currentSessionMessages} onOpenCase={handleSelectDoc} onCardAction={handleCardAction} + onErrorAction={handleErrorAction} /> {pendingProposal && (
@@ -1372,7 +1813,8 @@ export default function App() {
} @@ -1481,8 +1923,8 @@ export default function App() { canAssignLead={canAssignLead} allowDevSwitch={systemEnv === 'dev'} onSwitchUser={(u) => { - const fresh = allUsers.find((x) => x.id === u.id) || u; - setCurrentUser(fresh); + const preferred = sessions.find((s) => s.sessionId === activeSessionId)?.projectId; + activateUserPrivateChat(resolveWorkbenchUser(u), preferred); }} onSelectDoc={handleSelectDoc} onRoleChangeForDoc={handleRoleChangeForDoc} @@ -1551,8 +1993,8 @@ export default function App() { currentUser={currentUser} allUsers={allUsers} onSwitchUser={(newUser) => { - const fresh = allUsers.find((u) => u.id === newUser.id) || newUser; - setCurrentUser(fresh); + const preferred = sessions.find((s) => s.sessionId === activeSessionId)?.projectId; + activateUserPrivateChat(resolveWorkbenchUser(newUser), preferred); }} /> diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index d37838f9..e9d59055 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -138,7 +138,7 @@ export const InputArea: React.FC = ({ }} > - 智能体协同小队正在调取法规并推演回复... + 项目助手正在调取知识并整理回复... )} @@ -188,7 +188,7 @@ export const InputArea: React.FC = ({ value={text} onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} - placeholder="向参谋小队提问、下达立项指令,或要求编制/修改交付物 (Enter 发送,Shift+Enter 换行)..." + placeholder="向项目助手提问、下达立项指令,或要求编制/修改交付物 (Enter 发送,Shift+Enter 换行)..." rows={1} style={{ flex: 1, diff --git a/web/src/components/chat/MessageStream.tsx b/web/src/components/chat/MessageStream.tsx index 2b7172e4..ccc44d4a 100644 --- a/web/src/components/chat/MessageStream.tsx +++ b/web/src/components/chat/MessageStream.tsx @@ -6,12 +6,14 @@ export interface MessageStreamProps { messages: ChatMessage[]; onOpenCase?: (docId: string) => void; onCardAction?: (cardId: string, actionId: string, payload?: any) => void; + onErrorAction?: (messageId: string, action: 'retry' | 'continue' | 'restart') => void; } export const MessageStream: React.FC = ({ messages, onOpenCase, onCardAction, + onErrorAction, }) => { const bottomRef = useRef(null); @@ -51,6 +53,7 @@ export const MessageStream: React.FC = ({ message={msg} onOpenCase={onOpenCase} onCardAction={onCardAction} + onErrorAction={onErrorAction} /> )) )}