Files
Agent-System/web/src/hooks/useSendMessage.ts
T
weichenandCursor 0b1040ca7e feat(web): scope template matching to the active workflow
- Document list returns workflow_key per item and active_workflow_key
- DeliverableItem keeps workflowKey through server sync
- resolveTaskTemplateForMessage filters the match pool and blocks
  cross-workflow mentions with switch guidance

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 08:09:36 +00:00

645 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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;
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,
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(server docId);失败硬拦,禁止本地 orphan 回退
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) {
return {
accepted: false,
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
};
}
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;
}
} 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,
);
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,
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 };
};
}