feat(web): integrate interactive cards into chat stream and wire docgov actions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:19 +00:00
committed by weichen
co-authored by Cursor
parent 656a19cbe2
commit d4ddd61443
7 changed files with 971 additions and 14 deletions
+277 -3
View File
@@ -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<DocumentBlock | null>(null);
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
const [pendingProposal, setPendingProposal] = useState<{
changes: string;
impact: string;
@@ -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={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<MessageStream messages={currentSessionMessages} onOpenCase={handleSelectDoc} />
<MessageStream
messages={currentSessionMessages}
onOpenCase={handleSelectDoc}
onCardAction={handleCardAction}
/>
{pendingProposal && (
<div style={{ padding: '0 16px' }}>
<ProposalConfirmCard
@@ -874,6 +1147,7 @@ export default function App() {
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
);
}}
highlightedBlockId={highlightedBlockId}
/>
{/* 底部法定流转动作条 (根据受控/共享/私密形态自适应展示) */}
@@ -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('确认执行');
});
});
});
+18 -2
View File
@@ -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<MessageBubbleProps> = ({ message, onOpenCase }) => {
export const MessageBubble: React.FC<MessageBubbleProps> = ({
message,
onOpenCase,
onCardAction,
}) => {
const isUser = message.role === 'user';
return (
@@ -132,6 +138,16 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onOpenCas
{message.recommendedCases && (
<RecommendedCaseCard cases={message.recommendedCases} onOpenCase={onOpenCase} />
)}
{/* 交互门禁卡片 */}
{message.interactiveCard && (
<div style={{ whiteSpace: 'normal', width: '100%' }}>
<InteractiveCard
card={message.interactiveCard}
onExecuteAction={onCardAction}
/>
</div>
)}
</div>
</div>
</div>
+13 -3
View File
@@ -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<MessageStreamProps> = ({ messages, onOpenCase }) => {
export const MessageStream: React.FC<MessageStreamProps> = ({
messages,
onOpenCase,
onCardAction,
}) => {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -41,7 +46,12 @@ export const MessageStream: React.FC<MessageStreamProps> = ({ messages, onOpenCa
</div>
) : (
messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} onOpenCase={onOpenCase} />
<MessageBubble
key={msg.id}
message={msg}
onOpenCase={onOpenCase}
onCardAction={onCardAction}
/>
))
)}
<div ref={bottomRef} style={{ height: '1px' }} />
+27 -5
View File
@@ -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<BlockRendererProps> = ({
@@ -31,6 +32,7 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
onDeleteBlock,
onOpenComments,
onAskAgent,
highlightedBlockId,
}) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [contextMenu, setContextMenu] = useState<{
@@ -40,6 +42,15 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
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<BlockRendererProps> = ({
{/* 块流 */}
{children.map((block, idx) => {
const isHovered = hoveredIndex === idx;
const isHighlighted = !!highlightedBlockId && highlightedBlockId === block.id;
const commentCount = block.comments?.length || 0;
return (
<div
key={block.id || idx}
id={block.id ? `block-${block.id}` : undefined}
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
onDoubleClick={() => {
@@ -138,9 +151,18 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
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',
}}
>
+289
View File
@@ -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');
});
+264
View File
@@ -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;
});
}