Open member free-lane for chat initiation; auto-claim on author view; harden lease persist/ownership with readable DOC_BUSY; add notification inbox and supplement-impact HTTP decision APIs wired to composition root.
661 lines
26 KiB
TypeScript
661 lines
26 KiB
TypeScript
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||
import { createFreeformDoc } from '../api/freeformDoc';
|
||
import { proposeModification } from '../api/docgov';
|
||
import { uploadProjectAsset } from '../api/projectAssets';
|
||
import { saveFile } from '../data/fileStore';
|
||
import {
|
||
createRejectConfirmationCard,
|
||
decrementActiveCardsTtl,
|
||
findLastActiveCard,
|
||
findMatchingBlockForReview,
|
||
isAffirmativeResponse,
|
||
} from '../engine/cardActionEngine';
|
||
import { markdownToBlockAST } from '../engine/markdownToBlock';
|
||
import { hasDocumentWriteIntent, resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||
import { createClientTurnId } from '../lib/createClientTurnId';
|
||
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
|
||
import { holdsDocDuty } from '../lib/docGovActionVisibility';
|
||
import { resolveSessionProjectId } from '../lib/sessionAdapter';
|
||
import { registerUploadAttachments } from '../lib/uploadFreeformRegister';
|
||
import type { DocumentAST } from '../types/block';
|
||
import type { UserProfile, UserRole } from '../types/docgov';
|
||
import type { SendMessageResult } from '../types/sendMessage';
|
||
import type {
|
||
ChatMessage,
|
||
DeliverableCategory,
|
||
DeliverableItem,
|
||
ProjectSession,
|
||
QuotedMessage,
|
||
TaskRunContext,
|
||
TaskRunMaterial,
|
||
} from '../types/workbench';
|
||
|
||
/**
|
||
* 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。
|
||
*/
|
||
async function readAttachmentsAsMaterials(
|
||
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
|
||
projectId?: number,
|
||
): Promise<TaskRunMaterial[]> {
|
||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||
const results: TaskRunMaterial[] = [];
|
||
for (const att of attachments) {
|
||
if (att.assetId && att.assetId > 0) {
|
||
results.push({ filename: att.name, asset_id: att.assetId });
|
||
continue;
|
||
}
|
||
if (!att.fileRaw) continue;
|
||
const extMatch = att.name.match(/\.([^.]+)$/);
|
||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||
if (!TEXT_EXTS.has(ext)) {
|
||
if (!projectId || projectId <= 0) {
|
||
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
|
||
}
|
||
const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name);
|
||
results.push({ filename: uploaded.filename || att.name, asset_id: uploaded.asset_id });
|
||
continue;
|
||
}
|
||
try {
|
||
const content = await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const text = reader.result as string;
|
||
const b64 = btoa(unescape(encodeURIComponent(text)));
|
||
resolve(b64);
|
||
};
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsText(att.fileRaw as Blob, 'utf-8');
|
||
});
|
||
results.push({ filename: att.name, content });
|
||
} catch {
|
||
throw new Error(`读取文本附件 ${att.name} 失败`);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
export interface PendingProposal {
|
||
proposalId: string;
|
||
changes: string;
|
||
impact: string;
|
||
risk: string;
|
||
patchBlockId?: string;
|
||
}
|
||
|
||
export interface SendMessageInput {
|
||
currentProjectId: number;
|
||
currentUser: UserProfile;
|
||
activeSessionId: string;
|
||
activeDocId: string;
|
||
activeDocIdRef: MutableRefObject<string>;
|
||
messages: ChatMessage[];
|
||
docAST: DocumentAST;
|
||
docCache: Record<string, DocumentAST>;
|
||
currentDocRole: UserRole;
|
||
deliverables: DeliverableItem[];
|
||
visibleDeliverables: DeliverableItem[];
|
||
currentProjectDeliverables: DeliverableItem[];
|
||
/** 当前项目激活工作流键;用于文档任务匹配池收窄。 */
|
||
activeWorkflowKey?: string;
|
||
/** 来自文档列表的 WORKFLOW_SCOPE_MODE;缺省 strict。 */
|
||
workflowScopeMode?: 'strict' | 'weighted';
|
||
sessions: ProjectSession[];
|
||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
|
||
setActiveDocId: Dispatch<SetStateAction<string>>;
|
||
setActiveCategory: Dispatch<SetStateAction<DeliverableCategory>>;
|
||
setActiveQuotes: Dispatch<SetStateAction<QuotedMessage[]>>;
|
||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||
setProposalError: Dispatch<SetStateAction<string | null>>;
|
||
setPendingProposal: Dispatch<SetStateAction<PendingProposal | null>>;
|
||
taskRunStream: { setThinking: (sessionId: string, thinking: boolean) => void };
|
||
handleCardAction: (cardId: string, actionId: string, payload?: any) => void;
|
||
runTaskAttempt: (task: TaskRunContext, asstId: string, mode: 'run', attemptIndex: number) => void;
|
||
runStreamAttempt: (
|
||
userText: string,
|
||
asstId: string,
|
||
attemptIndex: number,
|
||
opts?: { userMsgId?: string; clientTurnId?: string; quote?: QuotedMessage },
|
||
) => void;
|
||
handlePassReview: () => Promise<void>;
|
||
handleApprove: () => Promise<void>;
|
||
}
|
||
|
||
export function useSendMessage(input: SendMessageInput) {
|
||
const {
|
||
currentProjectId,
|
||
currentUser,
|
||
activeSessionId,
|
||
activeDocId,
|
||
activeDocIdRef,
|
||
messages,
|
||
docAST,
|
||
docCache,
|
||
currentDocRole,
|
||
deliverables,
|
||
visibleDeliverables,
|
||
currentProjectDeliverables,
|
||
activeWorkflowKey,
|
||
workflowScopeMode,
|
||
sessions,
|
||
setDeliverables,
|
||
setDocCache,
|
||
setActiveDocId,
|
||
setActiveCategory,
|
||
setActiveQuotes,
|
||
setMessages,
|
||
setProposalError,
|
||
setPendingProposal,
|
||
taskRunStream,
|
||
handleCardAction,
|
||
runTaskAttempt,
|
||
runStreamAttempt,
|
||
handlePassReview,
|
||
handleApprove,
|
||
} = input;
|
||
|
||
return async (
|
||
text: string,
|
||
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
|
||
quotes?: QuotedMessage[]
|
||
): Promise<SendMessageResult> => {
|
||
const trimmed = text.trim();
|
||
let clientTurnId: string;
|
||
try {
|
||
clientTurnId = createClientTurnId();
|
||
} catch (err) {
|
||
return {
|
||
accepted: false,
|
||
error: err instanceof Error ? err.message : '无法生成发送标识,请重试',
|
||
};
|
||
}
|
||
|
||
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
|
||
let processedAttachments = attachments;
|
||
if (attachments && attachments.length > 0) {
|
||
let registered;
|
||
try {
|
||
registered = await registerUploadAttachments({
|
||
attachments,
|
||
projectId: currentProjectId,
|
||
userId: currentUser.id,
|
||
userName: currentUser.name,
|
||
createFreeformDoc,
|
||
});
|
||
} catch (err) {
|
||
if (hasDocumentWriteIntent(trimmed)) {
|
||
return {
|
||
accepted: false,
|
||
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
|
||
};
|
||
}
|
||
console.warn('[upload] freeform register failed; continuing chat/summary', err);
|
||
registered = undefined;
|
||
}
|
||
|
||
if (registered) {
|
||
const newDeliverablesList = registered.map((r) => r.deliverable);
|
||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||
for (const r of registered) {
|
||
const baseTitle = r.deliverable.title;
|
||
const initialMd = r.detail.content_markdown;
|
||
const newAST = markdownToBlockAST(initialMd, {
|
||
docId: r.serverDocId,
|
||
title: baseTitle,
|
||
taskKey: 'user_requirements',
|
||
version: 'V1.0',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '',
|
||
approver: '',
|
||
});
|
||
newAST.governanceMode = 'freeform';
|
||
newAST.space = 'my_private';
|
||
newDocCacheEntries[r.serverDocId] = newAST;
|
||
if (r.attachment.fileRaw) {
|
||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||
);
|
||
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
|
||
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
|
||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
|
||
try {
|
||
const uploaded = await uploadProjectAsset(
|
||
currentProjectId,
|
||
r.attachment.fileRaw as Blob,
|
||
r.attachment.name,
|
||
);
|
||
if (r.deliverable.fileMeta) {
|
||
r.deliverable.fileMeta.assetId = uploaded.asset_id;
|
||
}
|
||
// 回填去重与解析状态,供消息附件卡片展示提示。
|
||
r.attachment.assetId = uploaded.asset_id;
|
||
r.attachment.deduplicated = uploaded.deduplicated === true;
|
||
r.attachment.parseState = uploaded.parse_state;
|
||
} catch (e) {
|
||
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
processedAttachments = registered.map((r) => r.attachment);
|
||
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
...newDocCacheEntries,
|
||
}));
|
||
|
||
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
|
||
setActiveDocId(processedAttachments[0].docId);
|
||
setActiveCategory('private');
|
||
}
|
||
}
|
||
}
|
||
|
||
const userMsg: ChatMessage = {
|
||
id: 'msg-' + Date.now(),
|
||
clientTurnId,
|
||
clientMessageId: `${clientTurnId}:user`,
|
||
sessionId: activeSessionId,
|
||
role: 'user',
|
||
authorName: currentUser.name,
|
||
content: text,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
attachments: processedAttachments,
|
||
quotes: quotes && quotes.length > 0 ? quotes : undefined,
|
||
};
|
||
setActiveQuotes([]);
|
||
|
||
// 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作)
|
||
const isAffirmative = isAffirmativeResponse(trimmed);
|
||
const lastActive = findLastActiveCard(messages, activeSessionId);
|
||
|
||
if (isAffirmative && lastActive) {
|
||
const { card } = lastActive;
|
||
const defaultAction = card.actions.find((a) => a.isAffirmativeDefault) || card.actions[0];
|
||
if (defaultAction) {
|
||
setMessages((prev) => [...prev, userMsg]);
|
||
handleCardAction(card.cardId, defaultAction.actionId, defaultAction.payload);
|
||
return { accepted: true };
|
||
}
|
||
}
|
||
|
||
// 递减消息列表中尚未完结的 active 卡片 ttlTurns,若 <= 0 则标记为 expired
|
||
setMessages((prev) => [...decrementActiveCardsTtl(prev, activeSessionId), userMsg]);
|
||
|
||
// 命中以下任一演示用关键词捷径时才走本地模拟分支 (与真实后端 SSE 路径互斥,判定条件与下方分支体一一对应)
|
||
const isGenDocShortcut =
|
||
text.includes('生成文档') ||
|
||
text.includes('起草文档') ||
|
||
text.includes('测算') ||
|
||
text.includes('推演') ||
|
||
text.includes('备忘') ||
|
||
text.includes('生成报告');
|
||
const isRejectShortcut = text.includes('驳回') || text.includes('退回');
|
||
const isModifyShortcut =
|
||
(text.includes('修改') || text.includes('更新') || text.includes('调整')) &&
|
||
docAST.isClaimed &&
|
||
currentDocRole === 'author';
|
||
const isReviewPassShortcut =
|
||
(text.includes('通过') || text.includes('同意')) &&
|
||
((docAST.lifecycleState === 'reviewing' &&
|
||
holdsDocDuty(currentUser.id, 'reviewer', {
|
||
authorId: docAST.authorId,
|
||
reviewerId: docAST.reviewerId,
|
||
approverId: docAST.approverId,
|
||
})) ||
|
||
(docAST.lifecycleState === 'approving' &&
|
||
holdsDocDuty(currentUser.id, 'approver', {
|
||
authorId: docAST.authorId,
|
||
reviewerId: docAST.reviewerId,
|
||
approverId: docAST.approverId,
|
||
})));
|
||
const sendActiveDoc =
|
||
deliverables.find((d) => d.docId === activeDocId) ||
|
||
visibleDeliverables.find((d) => d.docId === activeDocId);
|
||
const resolvedTask = resolveTaskTemplateForMessage(
|
||
text,
|
||
currentProjectDeliverables,
|
||
sendActiveDoc,
|
||
activeWorkflowKey,
|
||
workflowScopeMode,
|
||
);
|
||
if (resolvedTask?.wrongWorkflow) {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-wrong-wf-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '项目助手',
|
||
content: wrongWorkflowGuidance(resolvedTask.wrongWorkflow!),
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
status: 'completed',
|
||
},
|
||
]);
|
||
return { accepted: true };
|
||
}
|
||
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
|
||
|
||
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
|
||
if (resolvedTask?.taskKey) {
|
||
const asstId = 'ast-' + Date.now();
|
||
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
|
||
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
|
||
const clientTurnId = `turn_${Date.now()}`;
|
||
const projId = resolveSessionProjectId(currentProj, currentProjectId);
|
||
if (projId <= 0) {
|
||
console.error('[task] 无法解析会话所属项目', activeSessionId);
|
||
return { accepted: false };
|
||
}
|
||
const taskKey = resolvedTask.taskKey;
|
||
const matchedDeliverable = deliverables.find((d) => {
|
||
if (d.projectId !== projId) return false;
|
||
if (resolvedTask.docId) return d.docId === resolvedTask.docId;
|
||
return d.taskKey === taskKey;
|
||
});
|
||
const activeLease = matchedDeliverable?.editLease;
|
||
const leaseIsActive =
|
||
!!activeLease?.holderType &&
|
||
(!activeLease.expiresAt || new Date(activeLease.expiresAt).getTime() > Date.now());
|
||
if (leaseIsActive) {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-busy-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '项目助手',
|
||
content: `《${matchedDeliverable?.title || '该文档'}》正在由${activeLease?.holderType === 'squad' ? '项目助手' : '其他用户'}编辑/生成中。当前请求未启动新任务;完成后状态会自动解除。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
status: 'completed',
|
||
},
|
||
]);
|
||
return { accepted: true };
|
||
}
|
||
const baseTask: TaskRunContext = {
|
||
taskKey,
|
||
projectName: currentProj?.title || '医疗器械研发演示项目',
|
||
customerName: '北京水木东方',
|
||
deviceType: currentProj?.deviceType || '医疗器械',
|
||
sessionId: activeSessionId,
|
||
userId: currentUser.id,
|
||
projectId: projId,
|
||
clientTurnId,
|
||
userMessage: text,
|
||
docId:
|
||
resolvedTask.docId ||
|
||
canonicalDocIdForTask(
|
||
projId,
|
||
taskKey,
|
||
deliverables,
|
||
matchedDeliverable?.docId,
|
||
),
|
||
};
|
||
const placeholder: ChatMessage = {
|
||
id: asstId,
|
||
clientTurnId,
|
||
clientMessageId: `${clientTurnId}:assistant`,
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '项目助手',
|
||
content: '',
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
status: 'pending',
|
||
livePhaseLabel: '任务执行中…',
|
||
taskRequest: baseTask,
|
||
};
|
||
setMessages((prev) => [...prev, placeholder]);
|
||
// 异步读取附件内容后再触发任务,避免文档材料丢失
|
||
void (async () => {
|
||
const materials = await readAttachmentsAsMaterials(
|
||
processedAttachments || [],
|
||
baseTask.projectId,
|
||
);
|
||
const task: TaskRunContext = materials.length > 0 ? { ...baseTask, materials } : baseTask;
|
||
runTaskAttempt(task, asstId, 'run', 0);
|
||
})();
|
||
return { accepted: true };
|
||
}
|
||
|
||
if (!isMockShortcut) {
|
||
// 真实后端 SSE 路径:立即挂起「等待中」占位气泡,无人为延迟,随后由 runStreamAttempt 驱动状态机
|
||
// (pending → streaming_status → completed/failed/auto_retrying/cancelled),严禁静默兜底话术
|
||
const asstId = 'ast-' + Date.now();
|
||
const placeholder: ChatMessage = {
|
||
id: asstId,
|
||
clientTurnId,
|
||
clientMessageId: `${clientTurnId}:assistant`,
|
||
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, {
|
||
userMsgId: userMsg.id,
|
||
clientTurnId,
|
||
quote: quotes && quotes.length > 0 ? quotes[0] : undefined,
|
||
});
|
||
return { accepted: true };
|
||
}
|
||
|
||
taskRunStream.setThinking(activeSessionId, true);
|
||
|
||
setTimeout(() => {
|
||
taskRunStream.setThinking(activeSessionId, false);
|
||
|
||
// 1. 用户要求助手生成新文档/测算/推演/备忘 (默认自动归档至【我的私密生成】,保护隐私)
|
||
if (isGenDocShortcut) {
|
||
const newDocId = `doc-private-${Date.now()}`;
|
||
const docTitle = text.includes('备忘')
|
||
? `${currentUser.name}专属:临床与商业推演备忘录`
|
||
: text.includes('测算')
|
||
? `${currentUser.name}专属:BOM器件成本与毛利灵敏度测算`
|
||
: `${currentUser.name}专属:AI协同专项推演草案`;
|
||
|
||
const newMeta: DeliverableItem = {
|
||
docId: newDocId,
|
||
projectId: resolveSessionProjectId(
|
||
sessions.find((s) => s.sessionId === activeSessionId),
|
||
currentProjectId,
|
||
) || 1,
|
||
taskKey: 'custom_draft',
|
||
title: docTitle,
|
||
stage: 'V0.5',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '',
|
||
approver: '',
|
||
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||
summary: `根据对话中您的需求“${text.slice(0, 60)}...”即时生成的个人私密草稿。`,
|
||
};
|
||
|
||
const sampleMd = `# ${docTitle}\n\n## 1. 产生背景与对话诉求\n根据用户【${currentUser.name}】于 ${new Date().toLocaleTimeString()} 提出的协同要求:“${text}”,参谋小队即时推演生成本专属文档。\n\n> 🔒 [隐私受控标记] 本文档为用户个人私密生成物,仅 ${currentUser.name} 本人可见,不进入项目固定受控审签体系。若推演成熟,可随时点击下方「一键共享给项目组」转为协作参考资料。\n\n## 2. 核心分析与推演结论\n依据系统知识库与工程模型,对关键指标与风险进行分解:\n- 技术可行性评级:A 级(具备清晰工程落地路径)\n- 建议跟进措施:优化元器件批量采购配额,规避长周期交货风险。\n`;
|
||
|
||
const parsedAst = markdownToBlockAST(sampleMd, {
|
||
docId: newDocId,
|
||
title: docTitle,
|
||
taskKey: 'custom_draft',
|
||
version: 'V0.1',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
});
|
||
|
||
setDeliverables((prev) => [newMeta, ...prev]);
|
||
setDocCache((prev) => ({ ...prev, [newDocId]: parsedAst }));
|
||
setActiveCategory('private');
|
||
setActiveDocId(newDocId);
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🔒 个人私密文档已生成',
|
||
content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物)
|
||
if (isRejectShortcut) {
|
||
const targetReviewDoc =
|
||
docAST.lifecycleState === 'reviewing'
|
||
? docAST
|
||
: deliverables.find((d) => d.lifecycleState === 'reviewing')
|
||
? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST)
|
||
: null;
|
||
|
||
if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') {
|
||
const matchingBlock = findMatchingBlockForReview(targetReviewDoc);
|
||
const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock);
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-reject-gate-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🛡️ 审核驳回门禁',
|
||
content: `检测到您对《${targetReviewDoc.title}》提出驳回审核意图。驳回将退回编制阶段并解除锁定,同时向编制人下达整改批注。请确认操作:`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
interactiveCard: card,
|
||
},
|
||
]);
|
||
return;
|
||
} else {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '⚠️ 状态校验提示',
|
||
content: `当前受控文档《${docAST.title}》处于【${docAST.lifecycleState}】阶段,非待审核状态,无需执行驳回。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
|
||
if (docAST.isClaimed && currentDocRole === 'author') {
|
||
const changes =
|
||
'拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。';
|
||
const impact = '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。';
|
||
const risk = '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。';
|
||
const docId = activeDocIdRef.current;
|
||
void (async () => {
|
||
try {
|
||
setProposalError(null);
|
||
const prop = await proposeModification({
|
||
doc_id: docId,
|
||
triggered_by: 'web_ui',
|
||
proposed_changes: changes,
|
||
impact_analysis: impact,
|
||
risk_assessment: risk,
|
||
});
|
||
setPendingProposal({
|
||
proposalId: prop.proposal_id,
|
||
changes: prop.proposed_changes || changes,
|
||
impact: prop.impact_analysis || impact,
|
||
risk: prop.risk_assessment || risk,
|
||
});
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🛡️ 文档认领保护机制拦截',
|
||
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】(${prop.proposal_id})。请您确认是否准许本次修改。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
setProposalError(message);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '⚠️ 变更提案失败',
|
||
content: `未能创建变更提案:${message}`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
}
|
||
})();
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (text.includes('通过') || text.includes('同意')) {
|
||
const duties = {
|
||
authorId: docAST.authorId,
|
||
reviewerId: docAST.reviewerId,
|
||
approverId: docAST.approverId,
|
||
};
|
||
if (docAST.lifecycleState === 'reviewing' && holdsDocDuty(currentUser.id, 'reviewer', duties)) {
|
||
void handlePassReview();
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '📋 审核通过流转',
|
||
content: `已确认审核通过!《${docAST.title}》状态已流转至【待批准 (approving)】,已呈报王总监终审。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
if (docAST.lifecycleState === 'approving' && holdsDocDuty(currentUser.id, 'approver', duties)) {
|
||
void handleApprove();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// P2:模板填报演示已由真实 task stream 链路替代(见上方 matchedTemplate 分支)
|
||
}, 1000);
|
||
|
||
return { accepted: true };
|
||
};
|
||
}
|