diff --git a/web/src/App.tsx b/web/src/App.tsx index 2bbc4c3f..3c9e4739 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -118,6 +118,7 @@ import { getTemplateDefinitionByTaskKey, buildTemplateASTForProject, } from './engine/templateFactory'; +import { matchTemplateDeliverable } from './engine/templateMatch'; import { loadSessions, saveSessions, @@ -2043,7 +2044,28 @@ export default function App() { }; // 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用) - const buildNetworkErrorInfo = (httpStatus?: number): ChatErrorInfo => { + const buildNetworkErrorInfo = ( + httpStatus?: number, + opts?: { errorCode?: string; detail?: string }, + ): ChatErrorInfo => { + const code = opts?.errorCode || ''; + const detail = opts?.detail || ''; + if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') { + return { + userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试', + code, + class: 'policy', + actions: ['retry'], + }; + } + if (code === 'DOC_DUTY_FORBIDDEN') { + return { + userMessage: detail || '当前账号不是该文档任务的职责人,无法执行', + code, + class: 'auth', + actions: ['retry'], + }; + } if (httpStatus === 401) { return { userMessage: '登录已失效,请重新登录后再试', @@ -2053,9 +2075,17 @@ export default function App() { }; } if (httpStatus === 403) { + if (code === 'ERR_FORBIDDEN' || detail.includes('无权访问该项目')) { + return { + userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份', + code: code || 'ERR_FORBIDDEN', + class: 'auth', + actions: ['retry'], + }; + } return { - userMessage: '无权访问该会话或项目,请确认登录账号与会话归属', - code: 'SESSION_FORBIDDEN', + userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段', + code: code || 'ERR_FORBIDDEN', class: 'auth', actions: ['retry'], }; @@ -2283,7 +2313,13 @@ export default function App() { // #region agent log fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:runStreamAttempt.catch',message:'stream fetch failed',data:{httpStatus,sessionId:streamSessionId,userId:currentUser.id},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{}); // #endregion - failOrRetry(buildNetworkErrorInfo(httpStatus)); + const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; + failOrRetry( + buildNetworkErrorInfo(httpStatus, { + errorCode: errMeta.errorCode, + detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), + }), + ); }); }; @@ -2456,7 +2492,13 @@ export default function App() { // #region agent log fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:runTaskAttempt.catch',message:'task run failed',data:{httpStatus,taskProjectId:task.projectId,sessionId:task.sessionId,taskKey:task.taskKey},timestamp:Date.now(),hypothesisId:'H5'})}).catch(()=>{}); // #endregion - failOrRetry(buildNetworkErrorInfo(httpStatus)); + const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; + failOrRetry( + buildNetworkErrorInfo(httpStatus, { + errorCode: errMeta.errorCode, + detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), + }), + ); }); }; @@ -2665,25 +2707,7 @@ export default function App() { (text.includes('通过') || text.includes('同意')) && ((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') || (docAST.lifecycleState === 'approving' && currentDocRole === 'approver')); - const matchedTemplate = currentProjectDeliverables.find((d) => { - if (!d.isTemplateOnly) return false; - if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) { - return d.taskKey === 'intake' || d.taskKey === 'user_requirements'; - } - if (text.includes('调研') || text.includes('竞品')) { - return d.taskKey === 'research'; - } - if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) { - return d.taskKey === 'feasibility_reg' || d.taskKey === 'feasibility_dev'; - } - if (text.includes('设计') || text.includes('规格') || text.includes('架构')) { - return d.taskKey === 'prelim_design'; - } - if (text.includes('报价') || text.includes('工时') || text.includes('预算')) { - return d.taskKey === 'cdo_quote'; - } - return false; - }); + const matchedTemplate = matchTemplateDeliverable(text, currentProjectDeliverables); const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut; // P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复) @@ -2783,7 +2807,10 @@ export default function App() { const newMeta: DeliverableItem = { docId: newDocId, - projectId: sessions.find((s) => s.sessionId === activeSessionId)?.projectId || 1, + projectId: resolveSessionProjectId( + sessions.find((s) => s.sessionId === activeSessionId), + currentProjectId, + ) || 1, taskKey: 'custom_draft', title: docTitle, stage: 'V0.5', diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index bc0add34..3e5ba218 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -26,15 +26,20 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise{}); + // #endregion throw Object.assign( new Error(`create task run failed: HTTP ${res.status}${detail ? `: ${detail}` : ''}`), - { httpStatus: res.status }, + { httpStatus: res.status, errorCode, detail }, ); } return res.json(); diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts new file mode 100644 index 00000000..1832998f --- /dev/null +++ b/web/src/engine/templateMatch.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { matchTemplateDeliverable } from './templateMatch'; +import { DeliverableItem } from '../types/workbench'; + +const base: Omit = { + docId: 'doc-1', + projectId: 143, + stage: 'V0.1', + category: 'workflow', + creatorId: 9, + creatorName: 'admin', + isFixedWorkflow: true, + isTemplateOnly: true, + lifecycleState: 'drafting', + isClaimed: false, + author: '', + reviewer: '', + approver: '', + updatedAt: '', +}; + +const pool: DeliverableItem[] = [ + { ...base, docId: 'doc-intake', taskKey: 'user_requirements', title: '用户需求表', stage: 'V0.5' }, + { ...base, docId: 'doc-urs', taskKey: 'intake', title: '用户初始需求表', stage: 'V0.1' }, +]; + +test('matchTemplateDeliverable prefers intake for 初始需求表', () => { + const hit = matchTemplateDeliverable('根据上传的文档,开始编写用户初始需求表', pool); + assert.equal(hit?.taskKey, 'intake'); +}); + +test('matchTemplateDeliverable selects user_requirements for explicit URS', () => { + const hit = matchTemplateDeliverable('请编制用户需求表 URS', pool); + assert.equal(hit?.taskKey, 'user_requirements'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts new file mode 100644 index 00000000..050eb3af --- /dev/null +++ b/web/src/engine/templateMatch.ts @@ -0,0 +1,57 @@ +import { DeliverableItem } from '../types/workbench'; + +function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] { + return deliverables.filter((d) => d.isTemplateOnly); +} + +/** 在 intake / user_requirements 等同语候选里按文案与阶段选择正确任务。 */ +function pickRequirementsTask(text: string, pool: DeliverableItem[]): DeliverableItem | undefined { + const intake = pool.find((d) => d.taskKey === 'intake'); + const userReq = pool.find((d) => d.taskKey === 'user_requirements'); + if (!intake && !userReq) return undefined; + if (intake && !userReq) return intake; + if (!intake && userReq) return userReq; + + const wantsUrs = + text.includes('用户需求表') || + text.includes('URS') || + (text.includes('用户需求') && !text.includes('初始')); + if (wantsUrs) return userReq; + + const wantsIntake = + text.includes('初始需求') || + text.includes('需求摸底') || + text.includes('摸底') || + text.includes('初始需求表'); + if (wantsIntake) return intake; + + // 含糊表述默认 V0.1 摸底表,避免误触 V0.5 用户需求表导致阶段门禁 403 + return intake; +} + +/** 根据用户输入在模板交付物中解析应触发的文档任务。 */ +export function matchTemplateDeliverable( + text: string, + deliverables: DeliverableItem[], +): DeliverableItem | undefined { + const pool = templatePool(deliverables); + + if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) { + return pickRequirementsTask(text, pool); + } + if (text.includes('调研') || text.includes('竞品')) { + return pool.find((d) => d.taskKey === 'research'); + } + if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) { + return pool.find( + (d) => d.taskKey === 'feasibility_reg' || d.taskKey === 'feasibility_dev', + ); + } + if (text.includes('设计') || text.includes('规格') || text.includes('架构')) { + return pool.find((d) => d.taskKey === 'prelim_design'); + } + if (text.includes('报价') || text.includes('工时') || text.includes('预算')) { + return pool.find((d) => d.taskKey === 'cdo_quote'); + } + return undefined; +}