diff --git a/web/src/App.tsx b/web/src/App.tsx index 0a55f111..d9aba193 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,11 +15,28 @@ 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, DeliverableItem, DeliverableCategory } from './types/workbench'; +import { + ProjectSession, + ChatMessage, + DeliverableItem, + DeliverableCategory, + InteractiveCardData, +} from './types/workbench'; import { UserRole, UserProfile, DocPermissionRole } from './types/docgov'; import { MOCK_DOCUMENTS } from './data/mockDocuments'; import { exportDocumentToDocx } from './engine/docxRenderer'; import { exportDocumentToXlsx } from './engine/xlsxRenderer'; +import { + findMatchingBlockForReview, + createRejectConfirmationCard, + isAffirmativeResponse, + findLastActiveCard, + decrementActiveCardsTtl, + applyConfirmRejectToDoc, + applyUndoRejectToDoc, + resolveCardInMessages, + cancelCardInMessages, +} from './engine/cardActionEngine'; import { loadSessions, saveSessions, @@ -402,6 +419,7 @@ export default function App() { // 模态弹窗与侧栏 const [editingBlock, setEditingBlock] = useState(null); const [commentingBlock, setCommentingBlock] = useState(null); + const [highlightedBlockId, setHighlightedBlockId] = useState(null); const [pendingProposal, setPendingProposal] = useState<{ changes: string; impact: string; @@ -423,8 +441,199 @@ export default function App() { return messages.filter((m) => m.sessionId === activeSessionId); }, [messages, activeSessionId]); + // 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动) + 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 && MOCK_DOCUMENTS[targetDocId]) { + const meta = 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: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', + }, + ], + }, + ]); + } + }; + // 发送消息与对话驱动双轨逻辑 const handleSendMessage = (text: string, attachments?: { name: string; size: string; type: string }[]) => { + const trimmed = text.trim(); const userMsg: ChatMessage = { id: 'msg-' + Date.now(), sessionId: activeSessionId, @@ -434,7 +643,23 @@ export default function App() { timestamp: new Date().toTimeString().substring(0, 5), attachments, }; - setMessages((prev) => [...prev, userMsg]); + + // 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作) + 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]); setIsThinking(true); setTimeout(() => { @@ -509,6 +734,50 @@ export default function App() { return; } + // 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物) + 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; + + 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({ @@ -813,7 +1082,11 @@ export default function App() { onOpenSettingsModal={() => setSettingsModalOpen(true)} chatContent={
- + {pendingProposal && (
150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。` ); }} + highlightedBlockId={highlightedBlockId} /> {/* 底部法定流转动作条 (根据受控/共享/私密形态自适应展示) */} diff --git a/web/src/components/chat/InteractiveCard.test.ts b/web/src/components/chat/InteractiveCard.test.ts index 02e92227..fe4dfab5 100644 --- a/web/src/components/chat/InteractiveCard.test.ts +++ b/web/src/components/chat/InteractiveCard.test.ts @@ -1,7 +1,9 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; -import { InteractiveCardData, CardActionItem } from '../../types/workbench'; +import { InteractiveCardData, CardActionItem, ChatMessage } from '../../types/workbench'; +import { MessageBubble } from './MessageBubble'; +import { MessageStream } from './MessageStream'; import { InteractiveCard, getCardTypeMeta, @@ -368,4 +370,84 @@ describe('InteractiveCard Logic', () => { expect(none.level).toBe('none'); }); }); + + describe('MessageBubble and MessageStream Card Integration', () => { + it('should render InteractiveCard inside MessageBubble when message has interactiveCard', () => { + const cardData: InteractiveCardData = { + cardId: 'card-bubble-test', + cardType: 'CONFIRMATION', + title: '审核驳回与返工确认', + targetDocId: 'doc-002', + targetDocTitle: '微创外科全球竞品深度调研', + sectionTitle: '第3节「电气安全与EMC」', + status: 'active', + ttlTurns: 3, + actions: [ + { actionId: 'confirm_reject', label: '确认驳回', style: 'danger', isAffirmativeDefault: true }, + { actionId: 'cancel', label: '放弃操作', style: 'subtle' }, + ], + }; + + const msg: ChatMessage = { + id: 'msg-ast-1', + sessionId: 'sess-001', + role: 'assistant', + content: '检测到您提出驳回意图,请确认是否执行:', + timestamp: '18:30', + interactiveCard: cardData, + }; + + const html = renderToStaticMarkup( + React.createElement(MessageBubble, { message: msg }) + ); + + // 气泡内应包含文本、卡片标题、徽章与操作按钮 + expect(html).toContain('检测到您提出驳回意图'); + expect(html).toContain('审核驳回与返工确认'); + expect(html).toContain('微创外科全球竞品深度调研'); + expect(html).toContain('第3节「电气安全与EMC」'); + expect(html).toContain('确认驳回'); + expect(html).toContain('放弃操作'); + }); + + it('should render InteractiveCard in MessageStream with multiple messages', () => { + const cardData: InteractiveCardData = { + cardId: 'card-stream-test', + cardType: 'CONFIRMATION', + title: '流转确认门禁', + status: 'active', + ttlTurns: 2, + actions: [ + { actionId: 'confirm', label: '确认执行', style: 'primary', isAffirmativeDefault: true }, + ], + }; + + const messages: ChatMessage[] = [ + { + id: 'u1', + sessionId: 'sess-001', + role: 'user', + content: '驳回这个文档', + timestamp: '18:29', + }, + { + id: 'a1', + sessionId: 'sess-001', + role: 'assistant', + content: '请确认是否驳回:', + timestamp: '18:30', + interactiveCard: cardData, + }, + ]; + + const html = renderToStaticMarkup( + React.createElement(MessageStream, { messages }) + ); + + expect(html).toContain('驳回这个文档'); + expect(html).toContain('请确认是否驳回:'); + expect(html).toContain('流转确认门禁'); + expect(html).toContain('确认执行'); + }); + }); }); diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 380378f9..166f2249 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -2,14 +2,20 @@ import React from 'react'; import { ChatMessage } from '../../types/workbench'; import { PhaseEventStrip } from './PhaseEventStrip'; import { RecommendedCaseCard } from './RecommendedCaseCard'; +import { InteractiveCard } from './InteractiveCard'; import { FileText, Bot, User } from 'lucide-react'; -interface MessageBubbleProps { +export interface MessageBubbleProps { message: ChatMessage; onOpenCase?: (docId: string) => void; + onCardAction?: (cardId: string, actionId: string, payload?: any) => void; } -export const MessageBubble: React.FC = ({ message, onOpenCase }) => { +export const MessageBubble: React.FC = ({ + message, + onOpenCase, + onCardAction, +}) => { const isUser = message.role === 'user'; return ( @@ -132,6 +138,16 @@ export const MessageBubble: React.FC = ({ message, onOpenCas {message.recommendedCases && ( )} + + {/* 交互门禁卡片 */} + {message.interactiveCard && ( +
+ +
+ )}
diff --git a/web/src/components/chat/MessageStream.tsx b/web/src/components/chat/MessageStream.tsx index 20dc7574..2b7172e4 100644 --- a/web/src/components/chat/MessageStream.tsx +++ b/web/src/components/chat/MessageStream.tsx @@ -2,12 +2,17 @@ import React, { useRef, useEffect } from 'react'; import { ChatMessage } from '../../types/workbench'; import { MessageBubble } from './MessageBubble'; -interface MessageStreamProps { +export interface MessageStreamProps { messages: ChatMessage[]; onOpenCase?: (docId: string) => void; + onCardAction?: (cardId: string, actionId: string, payload?: any) => void; } -export const MessageStream: React.FC = ({ messages, onOpenCase }) => { +export const MessageStream: React.FC = ({ + messages, + onOpenCase, + onCardAction, +}) => { const bottomRef = useRef(null); useEffect(() => { @@ -41,7 +46,12 @@ export const MessageStream: React.FC = ({ messages, onOpenCa ) : ( messages.map((msg) => ( - + )) )}
diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx index 0dc0f04b..ca8d1aa0 100644 --- a/web/src/components/doc/BlockRenderer.tsx +++ b/web/src/components/doc/BlockRenderer.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { DocumentAST, DocumentBlock } from '../../types/block'; import { UserRole } from '../../types/docgov'; import { HeadingBlockView } from './blocks/HeadingBlockView'; @@ -11,7 +11,7 @@ import { ListBlockView } from './blocks/ListBlockView'; import { BlockContextMenu } from './BlockContextMenu'; import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react'; -interface BlockRendererProps { +export interface BlockRendererProps { ast: DocumentAST; currentRole: UserRole; onEditBlock: (block: DocumentBlock) => void; @@ -20,6 +20,7 @@ interface BlockRendererProps { onDeleteBlock: (index: number) => void; onOpenComments: (block: DocumentBlock) => void; onAskAgent?: (content: string) => void; + highlightedBlockId?: string | null; } export const BlockRenderer: React.FC = ({ @@ -31,6 +32,7 @@ export const BlockRenderer: React.FC = ({ onDeleteBlock, onOpenComments, onAskAgent, + highlightedBlockId, }) => { const [hoveredIndex, setHoveredIndex] = useState(null); const [contextMenu, setContextMenu] = useState<{ @@ -40,6 +42,15 @@ export const BlockRenderer: React.FC = ({ index: number; } | null>(null); + useEffect(() => { + if (highlightedBlockId) { + const el = document.getElementById(`block-${highlightedBlockId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + }, [highlightedBlockId]); + const isEditable = currentRole === 'author' && ast.lifecycleState === 'drafting'; const children = ast.rootBlock.children || []; @@ -111,11 +122,13 @@ export const BlockRenderer: React.FC = ({ {/* 块流 */} {children.map((block, idx) => { const isHovered = hoveredIndex === idx; + const isHighlighted = !!highlightedBlockId && highlightedBlockId === block.id; const commentCount = block.comments?.length || 0; return (
setHoveredIndex(idx)} onMouseLeave={() => setHoveredIndex(null)} onDoubleClick={() => { @@ -138,9 +151,18 @@ export const BlockRenderer: React.FC = ({ borderRadius: 'var(--r-sm)', padding: '3px 8px', margin: '3px -8px', - border: isHovered && isEditable ? '1px dashed var(--brand-500)' : '1px solid transparent', - background: isHovered && isEditable ? 'var(--bg-hover)' : 'transparent', - transition: 'background 0.15s ease, border-color 0.15s ease', + border: isHighlighted + ? '2px solid var(--danger)' + : isHovered && isEditable + ? '1px dashed var(--brand-500)' + : '1px solid transparent', + background: isHighlighted + ? 'rgba(239, 68, 68, 0.08)' + : isHovered && isEditable + ? 'var(--bg-hover)' + : 'transparent', + boxShadow: isHighlighted ? '0 0 10px rgba(239, 68, 68, 0.25)' : 'none', + transition: 'background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease', cursor: isEditable ? 'text' : 'default', }} > diff --git a/web/src/engine/cardActionEngine.test.ts b/web/src/engine/cardActionEngine.test.ts new file mode 100644 index 00000000..902c3ab8 --- /dev/null +++ b/web/src/engine/cardActionEngine.test.ts @@ -0,0 +1,289 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + findMatchingBlockForReview, + createRejectConfirmationCard, + isAffirmativeResponse, + findLastActiveCard, + decrementActiveCardsTtl, + applyConfirmRejectToDoc, + applyUndoRejectToDoc, + resolveCardInMessages, + cancelCardInMessages, +} from './cardActionEngine'; +import { DocumentAST } from '../types/block'; +import { ChatMessage } from '../types/workbench'; + +const sampleAST: DocumentAST = { + docId: 'doc-002', + title: '微创外科全球竞品深度调研', + taskKey: 'research', + version: 'V1.0', + lifecycleState: 'reviewing', + isClaimed: true, + author: '张工', + reviewer: '李主管', + approver: '王总监', + updatedAt: '2026-09-08 11:30', + rootBlock: { + id: 'root', + type: 'document', + content: '', + children: [ + { + id: 'blk-h1', + type: 'heading', + level: 1, + numbering: '1', + content: '微创外科全球竞品深度调研', + }, + { + id: 'blk-comp', + type: 'heading', + level: 2, + numbering: '1.1', + content: '全球主要竞品横向性能对标与临床痛点', + }, + { + id: 'blk-emc', + type: 'paragraph', + content: '拟在第2节强化补充高频电刀共模抑制比设计指标,满足 GB 9706.1-2020 电气安全与 EMC 滤波要求。', + }, + { + id: 'blk-fto', + type: 'heading', + level: 2, + numbering: '1.2', + content: '自由实施 (FTO) 专利检索与回避流程', + }, + ], + }, +}; + +test('findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块', () => { + const matched = findMatchingBlockForReview(sampleAST); + assert.ok(matched); + // 应当优先匹配包含竞品/电气安全/EMC 的段落或标题 + assert.ok( + matched.content.includes('竞品') || + matched.content.includes('电气安全') || + matched.content.includes('EMC') + ); +}); + +test('findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块', () => { + const matched = findMatchingBlockForReview(sampleAST, '自由实施'); + assert.ok(matched); + assert.equal(matched.id, 'blk-fto'); + assert.ok(matched.content.includes('自由实施')); +}); + +test('createRejectConfirmationCard: 生成合规危险级驳回门禁卡片', () => { + const block = sampleAST.rootBlock.children![2]; + const card = createRejectConfirmationCard({ docId: 'doc-002', title: '微创外科调研' }, block); + + assert.equal(card.cardType, 'CONFIRMATION'); + assert.equal(card.targetDocId, 'doc-002'); + assert.equal(card.status, 'active'); + assert.equal(card.ttlTurns, 3); + assert.ok(card.actions.length >= 2); + + const confirmAction = card.actions.find((a) => a.actionId === 'confirm_reject'); + assert.ok(confirmAction); + assert.equal(confirmAction.style, 'danger'); + assert.equal(confirmAction.isAffirmativeDefault, true); + assert.equal(confirmAction.payload?.docId, 'doc-002'); +}); + +test('isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入', () => { + assert.equal(isAffirmativeResponse('好'), true); + assert.equal(isAffirmativeResponse('好的'), true); + assert.equal(isAffirmativeResponse('确认'), true); + assert.equal(isAffirmativeResponse('确认驳回'), true); + assert.equal(isAffirmativeResponse('同意'), true); + assert.equal(isAffirmativeResponse('同意驳回'), true); + assert.equal(isAffirmativeResponse('批准'), true); + assert.equal(isAffirmativeResponse('ok'), true); + assert.equal(isAffirmativeResponse('YES'), true); + + // 否定或无关意图 + assert.equal(isAffirmativeResponse('取消'), false); + assert.equal(isAffirmativeResponse('不驳回'), false); + assert.equal(isAffirmativeResponse('请帮我修改第二段'), false); +}); + +test('findLastActiveCard: 正确在会话历史中提取最新的待决策卡片', () => { + const messages: ChatMessage[] = [ + { + id: 'm1', + sessionId: 'sess-001', + role: 'assistant', + content: '旧消息已解决', + timestamp: '10:00', + interactiveCard: { + cardId: 'c1', + cardType: 'CONFIRMATION', + title: '旧卡片', + status: 'resolved', + ttlTurns: 0, + actions: [], + }, + }, + { + id: 'm2', + sessionId: 'sess-001', + role: 'assistant', + content: '当前待确认卡片', + timestamp: '10:05', + interactiveCard: { + cardId: 'c2', + cardType: 'CONFIRMATION', + title: '待驳回卡片', + status: 'active', + ttlTurns: 2, + actions: [{ actionId: 'confirm_reject', label: '确认', style: 'danger', isAffirmativeDefault: true }], + }, + }, + { + id: 'm3', + sessionId: 'sess-002', // 属于其他会话 + role: 'assistant', + content: '其他会话活跃卡片', + timestamp: '10:06', + interactiveCard: { + cardId: 'c3', + cardType: 'CONFIRMATION', + title: '其他会话卡片', + status: 'active', + ttlTurns: 3, + actions: [], + }, + }, + ]; + + const found = findLastActiveCard(messages, 'sess-001'); + assert.ok(found); + assert.equal(found.card.cardId, 'c2'); + assert.equal(found.card.status, 'active'); +}); + +test('decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档', () => { + const messages: ChatMessage[] = [ + { + id: 'm1', + sessionId: 'sess-001', + role: 'assistant', + content: '待确认', + timestamp: '10:00', + interactiveCard: { + cardId: 'c1', + cardType: 'CONFIRMATION', + title: '剩余1轮卡片', + status: 'active', + ttlTurns: 1, + actions: [], + }, + }, + { + id: 'm2', + sessionId: 'sess-001', + role: 'assistant', + content: '待确认2', + timestamp: '10:01', + interactiveCard: { + cardId: 'c2', + cardType: 'CONFIRMATION', + title: '剩余3轮卡片', + status: 'active', + ttlTurns: 3, + actions: [], + }, + }, + ]; + + const decremented = decrementActiveCardsTtl(messages, 'sess-001'); + // c1 原为 1,递减后为 0,应归档为 expired + assert.equal(decremented[0].interactiveCard?.ttlTurns, 0); + assert.equal(decremented[0].interactiveCard?.status, 'expired'); + + // c2 原为 3,递减后为 2,仍为 active + assert.equal(decremented[1].interactiveCard?.ttlTurns, 2); + assert.equal(decremented[1].interactiveCard?.status, 'active'); +}); + +test('applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环', () => { + const cardId = 'card-test-999'; + const reviewer = '李主管'; + const reason = '需按 GB 9706.1-2020 补充电气安全绝缘耐压参数'; + + // 1. 确认驳回 + const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc( + sampleAST, + cardId, + reviewer, + reason + ); + + // 状态流转验证:回退为 drafting + assert.equal(updatedDoc.lifecycleState, 'drafting'); + + // 批注挂载验证 + const targetBlock = updatedDoc.rootBlock.children?.find((b) => b.id === matchingBlock.id); + assert.ok(targetBlock); + assert.ok(targetBlock.comments && targetBlock.comments.length > 0); + + const comment = targetBlock.comments.find((c) => c.id === `cmt-reject-${cardId}`); + assert.ok(comment); + assert.equal(comment.authorName, reviewer); + assert.equal(comment.type, 'issue'); + assert.equal(comment.content, reason); + assert.equal(comment.resolved, false); + + // 2. 撤回驳回 (Undo) + const undoneDoc = applyUndoRejectToDoc(updatedDoc, cardId); + + // 状态流转验证:恢复为 reviewing + assert.equal(undoneDoc.lifecycleState, 'reviewing'); + + // 批注清理验证:对应 comment 已安全移除 + const targetBlockAfterUndo = undoneDoc.rootBlock.children?.find((b) => b.id === matchingBlock.id); + const remainingComment = targetBlockAfterUndo?.comments?.find((c) => c.id === `cmt-reject-${cardId}`); + assert.equal(remainingComment, undefined); +}); + +test('resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀', () => { + const messages: ChatMessage[] = [ + { + id: 'm1', + sessionId: 'sess-001', + role: 'assistant', + content: '卡片消息', + timestamp: '10:00', + interactiveCard: { + cardId: 'c1', + cardType: 'CONFIRMATION', + title: '审核确认', + status: 'active', + ttlTurns: 3, + actions: [], + }, + }, + ]; + + // 1. Resolve + const resolved = resolveCardInMessages( + messages, + 'c1', + '已正式退回编制阶段,批注已挂载至第3节', + '李主管', + '2026-09-08T18:00:00.000Z' + ); + assert.equal(resolved[0].interactiveCard?.status, 'resolved'); + assert.equal(resolved[0].interactiveCard?.resolutionSummary, '已正式退回编制阶段,批注已挂载至第3节'); + assert.equal(resolved[0].interactiveCard?.resolvedBy, '李主管'); + assert.equal(resolved[0].interactiveCard?.resolvedAt, '2026-09-08T18:00:00.000Z'); + + // 2. Cancel + const cancelled = cancelCardInMessages(messages, 'c1'); + assert.equal(cancelled[0].interactiveCard?.status, 'cancelled'); +}); diff --git a/web/src/engine/cardActionEngine.ts b/web/src/engine/cardActionEngine.ts new file mode 100644 index 00000000..63aa98d7 --- /dev/null +++ b/web/src/engine/cardActionEngine.ts @@ -0,0 +1,264 @@ +import { DocumentAST, DocumentBlock, BlockComment } from '../types/block'; +import { ChatMessage, InteractiveCardData } from '../types/workbench'; + +/** + * 扫描受控文档 AST,寻找与审核审查最匹配的锚定段落块 (如电气安全、EMC、竞品对比、临床痛点) + */ +export function findMatchingBlockForReview(ast: DocumentAST, hint?: string): DocumentBlock { + const children = ast.rootBlock?.children || []; + if (children.length === 0) { + return ast.rootBlock; + } + + // 1. 若外部指定了段落 ID 或文本线索,优先精确/包含匹配 + if (hint && hint.trim()) { + const trimmed = hint.trim().toLowerCase(); + const matched = children.find( + (b) => + b.id === hint || + b.content.toLowerCase().includes(trimmed) || + (b.properties?.tableData && + JSON.stringify(b.properties.tableData).toLowerCase().includes(trimmed)) + ); + if (matched) return matched; + } + + // 2. 依次匹配关键合规与审查高频词 + const keywords = ['电气安全', 'emc', '电磁兼容', '竞品', '对标', '临床痛点', '性能矩阵']; + for (const kw of keywords) { + const matched = children.find( + (b) => + b.content.toLowerCase().includes(kw) || + (b.properties?.tableData && + JSON.stringify(b.properties.tableData).toLowerCase().includes(kw)) + ); + if (matched) return matched; + } + + // 3. 降级:优先返回第 3 个段落块,或第 1 个 H2 标题块,或第 1 个子块 + const h2Block = children.find((b) => b.type === 'heading' && b.level === 2); + if (h2Block) return h2Block; + + return children[Math.min(2, children.length - 1)]; +} + +/** + * 构建审核驳回流转确认交互卡片 + */ +export function createRejectConfirmationCard( + targetDoc: { docId: string; title: string }, + matchingBlock?: DocumentBlock +): InteractiveCardData { + const cardId = 'card-reject-' + Date.now(); + const sectionLabel = matchingBlock?.numbering + ? `${matchingBlock.numbering} ${matchingBlock.content.slice(0, 24)}` + : '第3节「电气安全与合规风险对标」'; + + return { + cardId, + cardType: 'CONFIRMATION', + title: '审核驳回与返工确认', + targetDocId: targetDoc.docId, + targetDocTitle: targetDoc.title, + targetSectionId: matchingBlock?.id || 'sec-3', + sectionTitle: sectionLabel, + proposedContent: '【驳回审查意见】关键技术指标与竞品对比不充分,需补充电气安全与EMC合规佐证,退回编制人重新修订。', + impactRisk: '高风险:将退回至草稿编制阶段,正文锁定将解除', + status: 'active', + ttlTurns: 3, + createdAt: new Date().toTimeString().substring(0, 5), + actions: [ + { + actionId: 'confirm_reject', + label: '确认驳回并退回草稿', + style: 'danger', + isAffirmativeDefault: true, + payload: { + docId: targetDoc.docId, + reason: '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。', + targetSection: sectionLabel, + }, + }, + { + actionId: 'cancel', + label: '放弃操作', + style: 'subtle', + payload: { + docId: targetDoc.docId, + }, + }, + ], + }; +} + +/** + * 判定用户输入是否属于肯定型双轨回复(好、确认、同意等) + */ +export function isAffirmativeResponse(text: string): boolean { + const lower = text.trim().toLowerCase(); + const affirmativeKeywords = ['好', '确认', '同意', '是的', '准许', '批准', '行', '确认驳回', '同意驳回', 'ok', 'yes']; + return affirmativeKeywords.some( + (kw) => + lower === kw || + lower.startsWith(kw) || + (lower.length <= 8 && lower.includes(kw)) + ); +} + +/** + * 查找消息历史中最近一条处于 active 状态的交互卡片消息 + */ +export function findLastActiveCard( + messages: ChatMessage[], + sessionId?: string +): { message: ChatMessage; card: InteractiveCardData } | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (sessionId && m.sessionId !== sessionId) continue; + if (m.interactiveCard && m.interactiveCard.status === 'active') { + return { message: m, card: m.interactiveCard }; + } + } + return null; +} + +/** + * 递减所有 active 卡片的有效交互轮次 (TTL);达到 0 自动归档为 expired + */ +export function decrementActiveCardsTtl( + messages: ChatMessage[], + sessionId?: string +): ChatMessage[] { + return messages.map((m) => { + if (sessionId && m.sessionId !== sessionId) return m; + if (m.interactiveCard && m.interactiveCard.status === 'active') { + const nextTtl = m.interactiveCard.ttlTurns - 1; + return { + ...m, + interactiveCard: { + ...m.interactiveCard, + ttlTurns: nextTtl, + status: nextTtl <= 0 ? 'expired' : m.interactiveCard.status, + }, + }; + } + return m; + }); +} + +/** + * 执行审核驳回对文档 AST 的变更:回退为 drafting 并挂载 issue 批注 + */ +export function applyConfirmRejectToDoc( + doc: DocumentAST, + cardId: string, + reviewerName: string, + reason: string, + hint?: string +): { updatedDoc: DocumentAST; matchingBlock: DocumentBlock } { + const matchingBlock = findMatchingBlockForReview(doc, hint); + const commentId = `cmt-reject-${cardId}`; + + const issueComment: BlockComment = { + id: commentId, + authorName: reviewerName, + authorRole: 'reviewer', + createdAt: new Date().toTimeString().substring(0, 5), + type: 'issue', + content: reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。', + resolved: false, + }; + + // 克隆子块并追加批注 + const updatedChildren = (doc.rootBlock.children || []).map((b) => { + if (b.id === matchingBlock.id) { + return { + ...b, + comments: [...(b.comments || []), issueComment], + }; + } + return b; + }); + + const updatedDoc: DocumentAST = { + ...doc, + lifecycleState: 'drafting', + rootBlock: { + ...doc.rootBlock, + children: updatedChildren, + }, + }; + + return { updatedDoc, matchingBlock }; +} + +/** + * 执行合法窗口期撤销驳回对文档 AST 的变更:恢复为 reviewing 并移除挂载的驳回批注 + */ +export function applyUndoRejectToDoc(doc: DocumentAST, cardId: string): DocumentAST { + const commentId = `cmt-reject-${cardId}`; + + const updatedChildren = (doc.rootBlock.children || []).map((b) => { + if (b.comments && b.comments.length > 0) { + return { + ...b, + comments: b.comments.filter((c) => c.id !== commentId), + }; + } + return b; + }); + + return { + ...doc, + lifecycleState: 'reviewing', + rootBlock: { + ...doc.rootBlock, + children: updatedChildren, + }, + }; +} + +/** + * 在消息列表中将指定卡片更新为 resolved + */ +export function resolveCardInMessages( + messages: ChatMessage[], + cardId: string, + resolutionSummary: string, + operatorName: string, + resolvedAt?: string +): ChatMessage[] { + return messages.map((m) => { + if (m.interactiveCard && m.interactiveCard.cardId === cardId) { + return { + ...m, + interactiveCard: { + ...m.interactiveCard, + status: 'resolved', + resolutionSummary, + resolvedAt: resolvedAt || new Date().toISOString(), + resolvedBy: operatorName, + }, + }; + } + return m; + }); +} + +/** + * 在消息列表中将指定卡片更新为 cancelled + */ +export function cancelCardInMessages(messages: ChatMessage[], cardId: string): ChatMessage[] { + return messages.map((m) => { + if (m.interactiveCard && m.interactiveCard.cardId === cardId) { + return { + ...m, + interactiveCard: { + ...m.interactiveCard, + status: 'cancelled', + }, + }; + } + return m; + }); +}