refactor(web): retry chat and task runs from one hook
Keep reconnect on the existing run id so a dropped stream does not start a second task. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,7 +46,7 @@ updated: 2026-09-19
|
||||
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
|
||||
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
|
||||
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
|
||||
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存、登录同步、文档补水、服务端文档同步、生命周期、发送消息和块编辑已分别抽出。立项、卡片动作和任务流仍在 `App.tsx` |
|
||||
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存、登录同步、文档补水、服务端文档同步、生命周期、发送消息、块编辑,以及对话流/任务流重试已分别抽出。立项和卡片动作仍在 `App.tsx` |
|
||||
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
|
||||
|
||||
仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
|
||||
|
||||
+28
-726
@@ -17,7 +17,6 @@ import {
|
||||
deleteFreeformDoc,
|
||||
} from './api/freeformDoc';
|
||||
import { upsertChatSession } from './api/chatSessions';
|
||||
import { listProjectWorkflowDocuments } from './api/projectDocuments';
|
||||
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
|
||||
import {
|
||||
documentRevisionInactiveHint,
|
||||
@@ -43,25 +42,10 @@ import {
|
||||
type CreateProjectSubmitPhase,
|
||||
} from './lib/createProjectSubmitGuard';
|
||||
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
|
||||
import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync';
|
||||
import { resolveCurrentDocRole } from './lib/currentDocRole';
|
||||
import {
|
||||
decideModificationProposal,
|
||||
} from './api/docgov';
|
||||
import {
|
||||
pollUntilPostTaskServerDoc,
|
||||
shouldSwitchTemplateSlotToServer,
|
||||
} from './lib/postTaskDeliverableSync';
|
||||
import {
|
||||
isRemoteTaskTerminal,
|
||||
nextAttemptOpts,
|
||||
resolveReconnectRunId,
|
||||
shouldSubscribeExistingRun,
|
||||
} from './lib/taskRunReconnect';
|
||||
import {
|
||||
GOVERNANCE_BLOCKED_HINT,
|
||||
isBlockedGovernanceStatus,
|
||||
} from './lib/blockedGovernanceCopy';
|
||||
import {
|
||||
buildControlledLoadingSkeleton,
|
||||
controlledHydrateFingerprint,
|
||||
@@ -69,9 +53,7 @@ import {
|
||||
shouldLoadControlledContent,
|
||||
} from './lib/controlledDocHydrate';
|
||||
import {
|
||||
controlledHydrateCandidateIds,
|
||||
dropRecordKeys,
|
||||
planControlledContentInvalidation,
|
||||
type ControlledInvalidationPlan,
|
||||
} from './lib/controlledContentInvalidation';
|
||||
import {
|
||||
@@ -100,11 +82,6 @@ import { ShieldAlert } from 'lucide-react';
|
||||
import { LoginView } from './components/auth/LoginView';
|
||||
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
|
||||
import { authClient } from './api/auth';
|
||||
import { streamChat, mapErrorInfo } from './api/chatStream';
|
||||
import { streamTask, buildTaskRequest, buildResumeRequest } from './api/taskStream';
|
||||
import type { StreamEvent } from './api/chatStream';
|
||||
import { shouldAutoRetryChat, shouldAutoRetryTask, autoRetryDelayMs, fallbackErrorClass } from './api/chatRetry';
|
||||
import { applyPhaseEvent } from './engine/phaseEventMapper';
|
||||
import { LoginResult, SystemAuthConfig } from './types/auth';
|
||||
import { markdownToBlockAST } from './engine/markdownToBlock';
|
||||
import { blockASTToMarkdown } from './engine/blockToMarkdown';
|
||||
@@ -112,12 +89,9 @@ import { DocumentAST, DocumentBlock } from './types/block';
|
||||
import {
|
||||
ProjectSession,
|
||||
ChatMessage,
|
||||
ChatErrorInfo,
|
||||
DeliverableItem,
|
||||
DeliverableCategory,
|
||||
InteractiveCardData,
|
||||
PhaseEvent,
|
||||
TaskRunContext,
|
||||
ProjectInfo,
|
||||
ProjectMember,
|
||||
ProjectMemberRole,
|
||||
@@ -158,7 +132,6 @@ import {
|
||||
getTemplateDefinitionByTaskKey,
|
||||
buildTemplateASTForProject,
|
||||
} from './engine/templateFactory';
|
||||
import { resolveTaskTemplateForMessage } from './engine/templateMatch';
|
||||
import {
|
||||
loadSessions,
|
||||
loadActiveSessionId,
|
||||
@@ -176,12 +149,12 @@ import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspace
|
||||
import { useBlockEditing } from './hooks/useBlockEditing';
|
||||
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
|
||||
import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions';
|
||||
import { useTaskRunAttempts } from './hooks/useTaskRunAttempts';
|
||||
import { useSendMessage } from './hooks/useSendMessage';
|
||||
import { useServerDocumentSync } from './hooks/useServerDocumentSync';
|
||||
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
|
||||
import { useTurnRuntime } from './hooks/useTurnRuntime';
|
||||
import { useTaskRunStream } from './hooks/useTaskRunStream';
|
||||
import { fetchChatTurnStatus } from './api/chatTurn';
|
||||
import { mergeSessionUser } from './lib/mergeSessionUser';
|
||||
import { RequestEpoch, shareInflight } from './data/initFanout';
|
||||
import {
|
||||
@@ -194,10 +167,10 @@ import { DigestTimelineModal } from './components/history/DigestTimelineModal';
|
||||
import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator';
|
||||
import { useUnreadMarks } from './hooks/useUnreadMarks';
|
||||
import { CategoryDock } from './components/chat/CategoryDock';
|
||||
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
|
||||
import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench';
|
||||
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
|
||||
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
|
||||
import { saveFile } from './data/fileStore';
|
||||
import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns';
|
||||
import { cancelTaskRun, getTaskRun } from './api/taskRuns';
|
||||
|
||||
export {
|
||||
ALL_INITIAL_DELIVERABLES,
|
||||
@@ -1688,701 +1661,30 @@ export default function App() {
|
||||
};
|
||||
|
||||
// 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用)
|
||||
const buildNetworkErrorInfo = (
|
||||
httpStatus?: number,
|
||||
opts?: { errorCode?: string; detail?: string },
|
||||
): ChatErrorInfo => {
|
||||
const code = opts?.errorCode || '';
|
||||
const detail = opts?.detail || '';
|
||||
if (code === 'DOC_BUSY' || code === 'document_busy') {
|
||||
return {
|
||||
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
|
||||
code: 'DOC_BUSY',
|
||||
class: 'fatal',
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') {
|
||||
return {
|
||||
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
|
||||
code,
|
||||
class: 'transient',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (code === 'DOC_DUTY_FORBIDDEN') {
|
||||
return {
|
||||
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
|
||||
code,
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (httpStatus === 401) {
|
||||
return {
|
||||
userMessage: '登录已失效,请重新登录后再试',
|
||||
code: 'ERR_UNAUTHENTICATED',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403) {
|
||||
if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') {
|
||||
return {
|
||||
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
|
||||
code: code || 'ERR_FORBIDDEN',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
|
||||
code: code || 'ERR_FORBIDDEN',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
userMessage: '网络连接异常,请检查网络后重试',
|
||||
code: 'NETWORK_ERROR',
|
||||
class: fallbackErrorClass({ network: true }),
|
||||
actions: ['retry'],
|
||||
};
|
||||
};
|
||||
|
||||
// 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。
|
||||
// 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。
|
||||
type StreamAttemptOpts = {
|
||||
userMsgId?: string;
|
||||
clientTurnId?: string;
|
||||
quote?: QuotedMessage;
|
||||
};
|
||||
|
||||
const runStreamAttempt = (
|
||||
userText: string,
|
||||
asstId: string,
|
||||
attemptIndex: number,
|
||||
opts?: StreamAttemptOpts
|
||||
) => {
|
||||
const quote = opts?.quote;
|
||||
const controller = new AbortController();
|
||||
const streamSessionId = activeSessionId;
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.registerTurn({
|
||||
clientTurnId: opts.clientTurnId,
|
||||
sessionId: streamSessionId,
|
||||
userMsgId: opts.userMsgId || '',
|
||||
asstId,
|
||||
status: 'pending',
|
||||
controller,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const currentProj = sessions.find((s) => s.sessionId === streamSessionId);
|
||||
let accumulated = '';
|
||||
let settled = false;
|
||||
|
||||
const updateAsst = (patch: Partial<ChatMessage>) => {
|
||||
setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ...patch } : m)));
|
||||
};
|
||||
|
||||
const scheduleRetry = (delayMs: number) => {
|
||||
setTimeout(() => {
|
||||
if (controller.signal.aborted) {
|
||||
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'cancelled');
|
||||
}
|
||||
return;
|
||||
}
|
||||
runStreamAttempt(userText, asstId, attemptIndex + 1, opts);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => {
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status);
|
||||
}
|
||||
};
|
||||
|
||||
const failOrRetry = (info: ChatErrorInfo) => {
|
||||
settled = true;
|
||||
if (shouldAutoRetryChat(info, attemptIndex)) {
|
||||
const delayMs = autoRetryDelayMs(attemptIndex);
|
||||
updateAsst({
|
||||
status: 'auto_retrying',
|
||||
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
|
||||
});
|
||||
scheduleRetry(delayMs);
|
||||
return;
|
||||
}
|
||||
updateAsst({
|
||||
status: 'failed',
|
||||
errorInfo: {
|
||||
...info,
|
||||
userMessage:
|
||||
attemptIndex > 0
|
||||
? `${info.userMessage}(请确认上一请求是否已在处理后再手动重试)`
|
||||
: info.userMessage,
|
||||
},
|
||||
livePhaseLabel: undefined,
|
||||
});
|
||||
finishTurn('failed');
|
||||
};
|
||||
|
||||
updateAsst({
|
||||
status: 'pending',
|
||||
livePhaseLabel: attemptIndex > 0 ? '正在手动重试…' : '助手正在处理…',
|
||||
content: '',
|
||||
errorInfo: undefined,
|
||||
});
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'pending');
|
||||
}
|
||||
|
||||
const bindUserServerId = (serverId: number) => {
|
||||
if (!opts?.userMsgId) return;
|
||||
chatWindow.replaceMessageServerId(opts.userMsgId, serverId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === opts.userMsgId ? { ...m, serverId } : m))
|
||||
);
|
||||
};
|
||||
|
||||
streamChat(
|
||||
{
|
||||
session_id: streamSessionId,
|
||||
user_id: currentUser.id,
|
||||
project_id: currentProj?.projectId || currentProjectId || 1,
|
||||
project_name: currentProj?.title || '肌骨超声AI辅助诊断系统',
|
||||
device_type: currentProj?.deviceType || '肌骨超声',
|
||||
stage: currentProj?.stage || 'V0.5',
|
||||
user_name: currentUser.name,
|
||||
user_role: currentDocRole,
|
||||
message: userText,
|
||||
client_turn_id: opts?.clientTurnId,
|
||||
quote: quote,
|
||||
},
|
||||
{
|
||||
onEvent: (ev) => {
|
||||
if (controller.signal.aborted || settled) return;
|
||||
switch (ev.type) {
|
||||
case 'turn_ack': {
|
||||
const userServerId = ev.turn?.user_message_id ?? ev.user_message_id;
|
||||
if (typeof userServerId === 'number' && userServerId > 0) {
|
||||
bindUserServerId(userServerId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'phase':
|
||||
case 'status': {
|
||||
const label = ev.phase?.message || ev.status?.message;
|
||||
updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' });
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delta': {
|
||||
accumulated += ev.delta || '';
|
||||
updateAsst({ status: 'streaming_status', content: accumulated });
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
failOrRetry(mapErrorInfo(ev));
|
||||
break;
|
||||
}
|
||||
case 'done': {
|
||||
settled = true;
|
||||
const userServerId = ev.done?.user_message_id ?? ev.user_message_id;
|
||||
const serverMsgId = ev.done?.message_id ?? ev.message_id ?? (ev as { messageId?: number }).messageId;
|
||||
if (typeof userServerId === 'number' && userServerId > 0) {
|
||||
bindUserServerId(userServerId);
|
||||
}
|
||||
if (typeof serverMsgId === 'number' && serverMsgId > 0) {
|
||||
chatWindow.replaceMessageServerId(asstId, serverMsgId);
|
||||
}
|
||||
const exportCap = ev.export_capability || ev.done?.export_capability;
|
||||
updateAsst({
|
||||
status: 'completed',
|
||||
livePhaseLabel: undefined,
|
||||
serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined,
|
||||
exportCapability: exportCap,
|
||||
});
|
||||
finishTurn('completed');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
opts?.clientTurnId ? { idempotencyKey: opts.clientTurnId } : undefined
|
||||
).catch(async (_err) => {
|
||||
if (settled) return;
|
||||
if (controller.signal.aborted) {
|
||||
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
finishTurn('cancelled');
|
||||
return;
|
||||
}
|
||||
if (opts?.clientTurnId) {
|
||||
try {
|
||||
const turnStatus = await fetchChatTurnStatus(streamSessionId, opts.clientTurnId);
|
||||
if (turnStatus?.status === 'completed' && turnStatus.assistantMessageId) {
|
||||
settled = true;
|
||||
if (turnStatus.userMessageId) bindUserServerId(turnStatus.userMessageId);
|
||||
chatWindow.replaceMessageServerId(asstId, turnStatus.assistantMessageId);
|
||||
updateAsst({
|
||||
status: 'completed',
|
||||
livePhaseLabel: undefined,
|
||||
serverId: turnStatus.assistantMessageId,
|
||||
});
|
||||
finishTurn('completed');
|
||||
return;
|
||||
}
|
||||
if (turnStatus?.status === 'processing') {
|
||||
updateAsst({
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: '连接中断,服务端仍在处理…',
|
||||
});
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to structured failure
|
||||
}
|
||||
}
|
||||
const httpStatus =
|
||||
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
|
||||
? Number((_err as { httpStatus?: number }).httpStatus)
|
||||
: undefined;
|
||||
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(': '),
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区
|
||||
const runTaskAttempt = (
|
||||
task: TaskRunContext,
|
||||
asstId: string,
|
||||
mode: 'run' | 'resume' | 'restart',
|
||||
attemptIndex: number,
|
||||
opts?: { phaseEvents?: PhaseEvent[]; workDir?: string; reconnectRunId?: string }
|
||||
) => {
|
||||
const sessionId = task.sessionId || activeSessionId;
|
||||
let retainedRunId = resolveReconnectRunId({
|
||||
attemptIndex,
|
||||
optsReconnectRunId: opts?.reconnectRunId,
|
||||
});
|
||||
taskRunStream.abortSession(sessionId);
|
||||
// 重连保留显式 runId;禁止无条件 setRunId(null) 导致自动重试丢失
|
||||
if (retainedRunId) {
|
||||
taskRunStream.setRunId(sessionId, retainedRunId);
|
||||
} else {
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
taskRunStream.setController(sessionId, controller);
|
||||
|
||||
let accumulated = '';
|
||||
let settled = false;
|
||||
|
||||
const patchAsst = (patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m))
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleRetry = (delayMs: number) => {
|
||||
setTimeout(() => {
|
||||
if (controller.signal.aborted) {
|
||||
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
runTaskAttempt(task, asstId, mode, attemptIndex + 1, nextAttemptOpts(opts, retainedRunId));
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const failOrRetry = (info: ChatErrorInfo) => {
|
||||
settled = true;
|
||||
if (shouldAutoRetryTask(info, attemptIndex)) {
|
||||
const delayMs = autoRetryDelayMs(attemptIndex);
|
||||
patchAsst({
|
||||
status: 'auto_retrying',
|
||||
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
|
||||
});
|
||||
scheduleRetry(delayMs);
|
||||
return;
|
||||
}
|
||||
patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
};
|
||||
|
||||
const syncControlledDocsAfterTask = async (taskKey: string, preferredDocId?: string | null) => {
|
||||
if (!task.projectId || task.projectId <= 0 || !taskKey) return;
|
||||
const { merged, serverDocId } = await pollUntilPostTaskServerDoc({
|
||||
listDocs: () => listProjectWorkflowDocuments(task.projectId!).catch(() => []),
|
||||
getDeliverables: () => deliverablesRef.current,
|
||||
merge: (prev, docs) =>
|
||||
mergeWorkflowDocumentsFromServer(prev, docs, task.projectId!, currentUser),
|
||||
projectId: task.projectId,
|
||||
taskKey,
|
||||
preferredDocId,
|
||||
maxAttempts: 10,
|
||||
delayMs: 800,
|
||||
onMerged: (next) => {
|
||||
deliverablesRef.current = next;
|
||||
setDeliverables(next);
|
||||
applyControlledInvalidationPlan(
|
||||
planControlledContentInvalidation({
|
||||
reason: 'post_task',
|
||||
candidateIds: controlledHydrateCandidateIds(next, task.projectId),
|
||||
activeDocId: activeDocIdRef.current,
|
||||
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (
|
||||
serverDocId &&
|
||||
shouldSwitchTemplateSlotToServer({
|
||||
activeDocId,
|
||||
deliverables: merged,
|
||||
projectId: task.projectId,
|
||||
taskKey,
|
||||
serverDocId,
|
||||
})
|
||||
) {
|
||||
setActiveDocId(serverDocId);
|
||||
setActiveCategory('workflow');
|
||||
}
|
||||
};
|
||||
|
||||
// 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条
|
||||
patchAsst({
|
||||
status: 'pending',
|
||||
livePhaseLabel:
|
||||
mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…',
|
||||
errorInfo: undefined,
|
||||
...(mode === 'resume' || attemptIndex > 0
|
||||
? {}
|
||||
: { content: '', phaseEvents: [], taskWorkspaceId: undefined }),
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, true);
|
||||
|
||||
const onEvent = (ev: StreamEvent) => {
|
||||
if (controller.signal.aborted || settled) return;
|
||||
switch (ev.type) {
|
||||
case 'phase': {
|
||||
patchAsst((m) => ({
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: ev.phase?.message || m.livePhaseLabel,
|
||||
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
if (ev.status?.message) {
|
||||
patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delta': {
|
||||
accumulated += ev.delta || '';
|
||||
patchAsst({ status: 'streaming_status', content: accumulated });
|
||||
break;
|
||||
}
|
||||
case 'deliverable': {
|
||||
const title = ev.deliverable?.title;
|
||||
if (title) {
|
||||
accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`;
|
||||
patchAsst({ status: 'streaming_status', content: accumulated });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'governance': {
|
||||
const governance = ev.governance;
|
||||
const docId = governance?.doc_id;
|
||||
if (docId) {
|
||||
const leaseReleased = governance.action === 'lease_released';
|
||||
const editLease = leaseReleased
|
||||
? undefined
|
||||
: { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined };
|
||||
setDeliverables((prev) =>
|
||||
prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)),
|
||||
);
|
||||
setDocCache((prev) => {
|
||||
const ast = prev[docId];
|
||||
return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev;
|
||||
});
|
||||
}
|
||||
if (governance?.notification_msg) {
|
||||
patchAsst({
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: governance.notification_msg,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
failOrRetry(mapErrorInfo(ev));
|
||||
break;
|
||||
}
|
||||
case 'done': {
|
||||
settled = true;
|
||||
const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId;
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
void (async () => {
|
||||
let nextStatus: AssistantMessageStatus = 'completed';
|
||||
let preferredDocId: string | null = null;
|
||||
if (finishedRunId) {
|
||||
const remote = await getTaskRun(finishedRunId).catch(() => null);
|
||||
if (remote?.status) {
|
||||
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant');
|
||||
if (mapped) nextStatus = mapped;
|
||||
}
|
||||
}
|
||||
patchAsst({
|
||||
status: nextStatus,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: ev.done?.workspace_id,
|
||||
...(finishedRunId ? { taskRunId: finishedRunId } : {}),
|
||||
});
|
||||
// quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换
|
||||
if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') {
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId);
|
||||
}
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
})();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const startObserver = async () => {
|
||||
const body =
|
||||
mode === 'resume' && opts?.workDir
|
||||
? buildResumeRequest(task, opts.workDir)
|
||||
: buildTaskRequest(task);
|
||||
|
||||
if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') {
|
||||
// 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId
|
||||
const existingRunId = resolveReconnectRunId({
|
||||
attemptIndex,
|
||||
optsReconnectRunId: retainedRunId || opts?.reconnectRunId,
|
||||
});
|
||||
if (attemptIndex > 0 && existingRunId) {
|
||||
const remote = await getTaskRun(existingRunId).catch(() => null);
|
||||
if (remote && shouldSubscribeExistingRun(remote.status)) {
|
||||
retainedRunId = existingRunId;
|
||||
taskRunStream.setRunId(sessionId, existingRunId);
|
||||
patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' });
|
||||
await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal);
|
||||
return;
|
||||
}
|
||||
if (remote && isRemoteTaskTerminal(remote.status)) {
|
||||
settled = true;
|
||||
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed';
|
||||
patchAsst({
|
||||
status: mapped,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: existingRunId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
if (mapped === 'completed' || mapped === 'pending_author_confirmation') {
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createTaskRun({
|
||||
...body,
|
||||
session_id: task.sessionId,
|
||||
project_id: task.projectId,
|
||||
user_id: task.userId,
|
||||
client_turn_id: task.clientTurnId,
|
||||
user_message: task.userMessage,
|
||||
});
|
||||
retainedRunId = created.run_id;
|
||||
taskRunStream.setRunId(sessionId, created.run_id);
|
||||
patchAsst({ taskRunId: created.run_id });
|
||||
if (isBlockedGovernanceStatus(created.status)) {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'blocked_pending_governance',
|
||||
content: GOVERNANCE_BLOCKED_HINT,
|
||||
livePhaseLabel: undefined,
|
||||
taskRunId: created.run_id,
|
||||
});
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal);
|
||||
return;
|
||||
}
|
||||
|
||||
await streamTask(body, { onEvent }, controller.signal);
|
||||
};
|
||||
|
||||
void startObserver().catch(async (_err) => {
|
||||
if (settled) return;
|
||||
if (controller.signal.aborted) {
|
||||
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
const runId = retainedRunId || taskRunStream.activeTaskRunId || undefined;
|
||||
if (runId) {
|
||||
try {
|
||||
const remote = await getTaskRun(runId);
|
||||
if (remote.status === 'quality_failed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'quality_failed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (isBlockedGovernanceStatus(remote.status)) {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'blocked_pending_governance',
|
||||
content: GOVERNANCE_BLOCKED_HINT,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (remote.status === 'completed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'completed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
return;
|
||||
}
|
||||
if (remote.status === 'pending_author_confirmation') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'pending_author_confirmation',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (shouldSubscribeExistingRun(remote.status)) {
|
||||
// 原 run 仍在跑:重订阅,不 POST 新 run
|
||||
settled = false;
|
||||
retainedRunId = runId;
|
||||
patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId });
|
||||
await subscribeTaskRunEvents(runId, { onEvent }, controller.signal);
|
||||
return;
|
||||
}
|
||||
if (remote.status === 'failed' || remote.status === 'cancelled') {
|
||||
failOrRetry(buildNetworkErrorInfo());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to network retry path
|
||||
}
|
||||
}
|
||||
const httpStatus =
|
||||
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
|
||||
? Number((_err as { httpStatus?: number }).httpStatus)
|
||||
: undefined;
|
||||
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(': '),
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复)
|
||||
const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => {
|
||||
const target = messages.find((m) => m.id === messageId);
|
||||
if (!target) return;
|
||||
|
||||
// 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启
|
||||
if (target.taskRequest) {
|
||||
if (action === 'retry') {
|
||||
const userText = target.taskRequest.userMessage || '';
|
||||
const retryActive =
|
||||
deliverables.find((d) => d.docId === activeDocId) ||
|
||||
visibleDeliverables.find((d) => d.docId === activeDocId);
|
||||
const reResolved = resolveTaskTemplateForMessage(
|
||||
userText,
|
||||
currentProjectDeliverables,
|
||||
retryActive,
|
||||
);
|
||||
const retryTask = reResolved
|
||||
? {
|
||||
...target.taskRequest,
|
||||
taskKey: reResolved.taskKey,
|
||||
docId: reResolved.docId || target.taskRequest.docId,
|
||||
}
|
||||
: target.taskRequest;
|
||||
runTaskAttempt(retryTask, messageId, 'run', 0);
|
||||
} else if (action === 'continue') {
|
||||
const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId;
|
||||
if (!wsId) return;
|
||||
runTaskAttempt(target.taskRequest, messageId, 'resume', 0, {
|
||||
phaseEvents: target.phaseEvents,
|
||||
workDir: wsId,
|
||||
});
|
||||
} else {
|
||||
if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return;
|
||||
runTaskAttempt(target.taskRequest, messageId, 'restart', 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 闲聊路径(P1 行为不变:仅 retry)
|
||||
if (action !== 'retry') return;
|
||||
const sourceMsg = target.sourceUserMessageId
|
||||
? messages.find((m) => m.id === target.sourceUserMessageId)
|
||||
: undefined;
|
||||
const originalText = sourceMsg?.content?.trim();
|
||||
if (!originalText) return;
|
||||
runStreamAttempt(originalText, messageId, 0, {
|
||||
userMsgId: sourceMsg?.id,
|
||||
clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId,
|
||||
});
|
||||
};
|
||||
const { runStreamAttempt, runTaskAttempt, handleErrorAction } = useTaskRunAttempts({
|
||||
activeSessionId,
|
||||
activeDocId,
|
||||
currentProjectId,
|
||||
currentUser,
|
||||
currentDocRole,
|
||||
sessions,
|
||||
messages,
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
turnRuntime,
|
||||
taskRunStream,
|
||||
chatWindow,
|
||||
setMessages,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
setActiveDocId,
|
||||
setActiveCategory,
|
||||
applyControlledInvalidationPlan,
|
||||
});
|
||||
|
||||
// 用户主动停止:仅中止当前 session 的活跃 turn
|
||||
const handleStopStreaming = () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
test('chat and task attempts live in one hook', () => {
|
||||
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(new URL('./useTaskRunAttempts.ts', import.meta.url), 'utf8');
|
||||
assert.equal((app.match(/useTaskRunAttempts\(/g) || []).length, 1);
|
||||
assert.equal(app.includes('streamChat('), false);
|
||||
assert.equal(app.includes('createTaskRun('), false);
|
||||
assert.equal(hook.includes('streamChat'), true);
|
||||
assert.equal(hook.includes('createTaskRun'), true);
|
||||
assert.equal(hook.includes('shouldSubscribeExistingRun'), true);
|
||||
assert.equal(hook.includes('禁止无条件 setRunId(null)'), true);
|
||||
});
|
||||
@@ -0,0 +1,805 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import {
|
||||
autoRetryDelayMs,
|
||||
fallbackErrorClass,
|
||||
shouldAutoRetryChat,
|
||||
shouldAutoRetryTask,
|
||||
} from '../api/chatRetry';
|
||||
import { mapErrorInfo, streamChat, type StreamEvent } from '../api/chatStream';
|
||||
import { fetchChatTurnStatus } from '../api/chatTurn';
|
||||
import { mapTaskStatusToMessageStatus } from '../api/chatWindow';
|
||||
import { listProjectWorkflowDocuments } from '../api/projectDocuments';
|
||||
import { createTaskRun, getTaskRun, subscribeTaskRunEvents } from '../api/taskRuns';
|
||||
import { buildResumeRequest, buildTaskRequest, streamTask } from '../api/taskStream';
|
||||
import { applyPhaseEvent } from '../engine/phaseEventMapper';
|
||||
import { resolveTaskTemplateForMessage } from '../engine/templateMatch';
|
||||
import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy';
|
||||
import {
|
||||
controlledHydrateCandidateIds,
|
||||
planControlledContentInvalidation,
|
||||
type ControlledInvalidationPlan,
|
||||
} from '../lib/controlledContentInvalidation';
|
||||
import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync';
|
||||
import { createEditorHydrationGate } from '../lib/editorHydrationGate';
|
||||
import {
|
||||
pollUntilPostTaskServerDoc,
|
||||
shouldSwitchTemplateSlotToServer,
|
||||
} from '../lib/postTaskDeliverableSync';
|
||||
import {
|
||||
isRemoteTaskTerminal,
|
||||
nextAttemptOpts,
|
||||
resolveReconnectRunId,
|
||||
shouldSubscribeExistingRun,
|
||||
} from '../lib/taskRunReconnect';
|
||||
import type { DocumentAST } from '../types/block';
|
||||
import type { UserProfile, UserRole } from '../types/docgov';
|
||||
import type {
|
||||
AssistantMessageStatus,
|
||||
ChatErrorInfo,
|
||||
ChatMessage,
|
||||
DeliverableCategory,
|
||||
DeliverableItem,
|
||||
PhaseEvent,
|
||||
ProjectSession,
|
||||
QuotedMessage,
|
||||
TaskRunContext,
|
||||
} from '../types/workbench';
|
||||
import { useTaskRunStream } from './useTaskRunStream';
|
||||
import { useTurnRuntime } from './useTurnRuntime';
|
||||
|
||||
type Gate = ReturnType<typeof createEditorHydrationGate>;
|
||||
type TurnRuntime = ReturnType<typeof useTurnRuntime>;
|
||||
type TaskStream = ReturnType<typeof useTaskRunStream>;
|
||||
|
||||
export interface TaskRunAttemptsInput {
|
||||
activeSessionId: string;
|
||||
activeDocId: string;
|
||||
currentProjectId: number;
|
||||
currentUser: UserProfile;
|
||||
currentDocRole: UserRole;
|
||||
sessions: ProjectSession[];
|
||||
messages: ChatMessage[];
|
||||
deliverables: DeliverableItem[];
|
||||
visibleDeliverables: DeliverableItem[];
|
||||
currentProjectDeliverables: DeliverableItem[];
|
||||
deliverablesRef: MutableRefObject<DeliverableItem[]>;
|
||||
activeDocIdRef: MutableRefObject<string>;
|
||||
editorHydrationGateRef: MutableRefObject<Gate>;
|
||||
turnRuntime: TurnRuntime;
|
||||
taskRunStream: TaskStream;
|
||||
chatWindow: {
|
||||
replaceMessageServerId: (tempId: string, serverId: number) => void;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||||
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
|
||||
setActiveDocId: Dispatch<SetStateAction<string>>;
|
||||
setActiveCategory: Dispatch<SetStateAction<DeliverableCategory>>;
|
||||
applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void;
|
||||
}
|
||||
|
||||
export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
const {
|
||||
activeSessionId,
|
||||
activeDocId,
|
||||
currentProjectId,
|
||||
currentUser,
|
||||
currentDocRole,
|
||||
sessions,
|
||||
messages,
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
turnRuntime,
|
||||
taskRunStream,
|
||||
chatWindow,
|
||||
setMessages,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
setActiveDocId,
|
||||
setActiveCategory,
|
||||
applyControlledInvalidationPlan,
|
||||
} = input;
|
||||
|
||||
const buildNetworkErrorInfo = (
|
||||
httpStatus?: number,
|
||||
opts?: { errorCode?: string; detail?: string },
|
||||
): ChatErrorInfo => {
|
||||
const code = opts?.errorCode || '';
|
||||
const detail = opts?.detail || '';
|
||||
if (code === 'DOC_BUSY' || code === 'document_busy') {
|
||||
return {
|
||||
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
|
||||
code: 'DOC_BUSY',
|
||||
class: 'fatal',
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') {
|
||||
return {
|
||||
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
|
||||
code,
|
||||
class: 'transient',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (code === 'DOC_DUTY_FORBIDDEN') {
|
||||
return {
|
||||
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
|
||||
code,
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (httpStatus === 401) {
|
||||
return {
|
||||
userMessage: '登录已失效,请重新登录后再试',
|
||||
code: 'ERR_UNAUTHENTICATED',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403) {
|
||||
if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') {
|
||||
return {
|
||||
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
|
||||
code: code || 'ERR_FORBIDDEN',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
|
||||
code: code || 'ERR_FORBIDDEN',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
userMessage: '网络连接异常,请检查网络后重试',
|
||||
code: 'NETWORK_ERROR',
|
||||
class: fallbackErrorClass({ network: true }),
|
||||
actions: ['retry'],
|
||||
};
|
||||
};
|
||||
|
||||
// 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。
|
||||
// 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。
|
||||
type StreamAttemptOpts = {
|
||||
userMsgId?: string;
|
||||
clientTurnId?: string;
|
||||
quote?: QuotedMessage;
|
||||
};
|
||||
|
||||
const runStreamAttempt = (
|
||||
userText: string,
|
||||
asstId: string,
|
||||
attemptIndex: number,
|
||||
opts?: StreamAttemptOpts
|
||||
) => {
|
||||
const quote = opts?.quote;
|
||||
const controller = new AbortController();
|
||||
const streamSessionId = activeSessionId;
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.registerTurn({
|
||||
clientTurnId: opts.clientTurnId,
|
||||
sessionId: streamSessionId,
|
||||
userMsgId: opts.userMsgId || '',
|
||||
asstId,
|
||||
status: 'pending',
|
||||
controller,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const currentProj = sessions.find((s) => s.sessionId === streamSessionId);
|
||||
let accumulated = '';
|
||||
let settled = false;
|
||||
|
||||
const updateAsst = (patch: Partial<ChatMessage>) => {
|
||||
setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ...patch } : m)));
|
||||
};
|
||||
|
||||
const scheduleRetry = (delayMs: number) => {
|
||||
setTimeout(() => {
|
||||
if (controller.signal.aborted) {
|
||||
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'cancelled');
|
||||
}
|
||||
return;
|
||||
}
|
||||
runStreamAttempt(userText, asstId, attemptIndex + 1, opts);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => {
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status);
|
||||
}
|
||||
};
|
||||
|
||||
const failOrRetry = (info: ChatErrorInfo) => {
|
||||
settled = true;
|
||||
if (shouldAutoRetryChat(info, attemptIndex)) {
|
||||
const delayMs = autoRetryDelayMs(attemptIndex);
|
||||
updateAsst({
|
||||
status: 'auto_retrying',
|
||||
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
|
||||
});
|
||||
scheduleRetry(delayMs);
|
||||
return;
|
||||
}
|
||||
updateAsst({
|
||||
status: 'failed',
|
||||
errorInfo: {
|
||||
...info,
|
||||
userMessage:
|
||||
attemptIndex > 0
|
||||
? `${info.userMessage}(请确认上一请求是否已在处理后再手动重试)`
|
||||
: info.userMessage,
|
||||
},
|
||||
livePhaseLabel: undefined,
|
||||
});
|
||||
finishTurn('failed');
|
||||
};
|
||||
|
||||
updateAsst({
|
||||
status: 'pending',
|
||||
livePhaseLabel: attemptIndex > 0 ? '正在手动重试…' : '助手正在处理…',
|
||||
content: '',
|
||||
errorInfo: undefined,
|
||||
});
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'pending');
|
||||
}
|
||||
|
||||
const bindUserServerId = (serverId: number) => {
|
||||
if (!opts?.userMsgId) return;
|
||||
chatWindow.replaceMessageServerId(opts.userMsgId, serverId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === opts.userMsgId ? { ...m, serverId } : m))
|
||||
);
|
||||
};
|
||||
|
||||
streamChat(
|
||||
{
|
||||
session_id: streamSessionId,
|
||||
user_id: currentUser.id,
|
||||
project_id: currentProj?.projectId || currentProjectId || 1,
|
||||
project_name: currentProj?.title || '肌骨超声AI辅助诊断系统',
|
||||
device_type: currentProj?.deviceType || '肌骨超声',
|
||||
stage: currentProj?.stage || 'V0.5',
|
||||
user_name: currentUser.name,
|
||||
user_role: currentDocRole,
|
||||
message: userText,
|
||||
client_turn_id: opts?.clientTurnId,
|
||||
quote: quote,
|
||||
},
|
||||
{
|
||||
onEvent: (ev) => {
|
||||
if (controller.signal.aborted || settled) return;
|
||||
switch (ev.type) {
|
||||
case 'turn_ack': {
|
||||
const userServerId = ev.turn?.user_message_id ?? ev.user_message_id;
|
||||
if (typeof userServerId === 'number' && userServerId > 0) {
|
||||
bindUserServerId(userServerId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'phase':
|
||||
case 'status': {
|
||||
const label = ev.phase?.message || ev.status?.message;
|
||||
updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' });
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delta': {
|
||||
accumulated += ev.delta || '';
|
||||
updateAsst({ status: 'streaming_status', content: accumulated });
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
failOrRetry(mapErrorInfo(ev));
|
||||
break;
|
||||
}
|
||||
case 'done': {
|
||||
settled = true;
|
||||
const userServerId = ev.done?.user_message_id ?? ev.user_message_id;
|
||||
const serverMsgId = ev.done?.message_id ?? ev.message_id ?? (ev as { messageId?: number }).messageId;
|
||||
if (typeof userServerId === 'number' && userServerId > 0) {
|
||||
bindUserServerId(userServerId);
|
||||
}
|
||||
if (typeof serverMsgId === 'number' && serverMsgId > 0) {
|
||||
chatWindow.replaceMessageServerId(asstId, serverMsgId);
|
||||
}
|
||||
const exportCap = ev.export_capability || ev.done?.export_capability;
|
||||
updateAsst({
|
||||
status: 'completed',
|
||||
livePhaseLabel: undefined,
|
||||
serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined,
|
||||
exportCapability: exportCap,
|
||||
});
|
||||
finishTurn('completed');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
opts?.clientTurnId ? { idempotencyKey: opts.clientTurnId } : undefined
|
||||
).catch(async (_err) => {
|
||||
if (settled) return;
|
||||
if (controller.signal.aborted) {
|
||||
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
finishTurn('cancelled');
|
||||
return;
|
||||
}
|
||||
if (opts?.clientTurnId) {
|
||||
try {
|
||||
const turnStatus = await fetchChatTurnStatus(streamSessionId, opts.clientTurnId);
|
||||
if (turnStatus?.status === 'completed' && turnStatus.assistantMessageId) {
|
||||
settled = true;
|
||||
if (turnStatus.userMessageId) bindUserServerId(turnStatus.userMessageId);
|
||||
chatWindow.replaceMessageServerId(asstId, turnStatus.assistantMessageId);
|
||||
updateAsst({
|
||||
status: 'completed',
|
||||
livePhaseLabel: undefined,
|
||||
serverId: turnStatus.assistantMessageId,
|
||||
});
|
||||
finishTurn('completed');
|
||||
return;
|
||||
}
|
||||
if (turnStatus?.status === 'processing') {
|
||||
updateAsst({
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: '连接中断,服务端仍在处理…',
|
||||
});
|
||||
if (opts?.clientTurnId) {
|
||||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to structured failure
|
||||
}
|
||||
}
|
||||
const httpStatus =
|
||||
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
|
||||
? Number((_err as { httpStatus?: number }).httpStatus)
|
||||
: undefined;
|
||||
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(': '),
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区
|
||||
const runTaskAttempt = (
|
||||
task: TaskRunContext,
|
||||
asstId: string,
|
||||
mode: 'run' | 'resume' | 'restart',
|
||||
attemptIndex: number,
|
||||
opts?: { phaseEvents?: PhaseEvent[]; workDir?: string; reconnectRunId?: string }
|
||||
) => {
|
||||
const sessionId = task.sessionId || activeSessionId;
|
||||
let retainedRunId = resolveReconnectRunId({
|
||||
attemptIndex,
|
||||
optsReconnectRunId: opts?.reconnectRunId,
|
||||
});
|
||||
taskRunStream.abortSession(sessionId);
|
||||
// 重连保留显式 runId;禁止无条件 setRunId(null) 导致自动重试丢失
|
||||
if (retainedRunId) {
|
||||
taskRunStream.setRunId(sessionId, retainedRunId);
|
||||
} else {
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
taskRunStream.setController(sessionId, controller);
|
||||
|
||||
let accumulated = '';
|
||||
let settled = false;
|
||||
|
||||
const patchAsst = (patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m))
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleRetry = (delayMs: number) => {
|
||||
setTimeout(() => {
|
||||
if (controller.signal.aborted) {
|
||||
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
runTaskAttempt(task, asstId, mode, attemptIndex + 1, nextAttemptOpts(opts, retainedRunId));
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const failOrRetry = (info: ChatErrorInfo) => {
|
||||
settled = true;
|
||||
if (shouldAutoRetryTask(info, attemptIndex)) {
|
||||
const delayMs = autoRetryDelayMs(attemptIndex);
|
||||
patchAsst({
|
||||
status: 'auto_retrying',
|
||||
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
|
||||
});
|
||||
scheduleRetry(delayMs);
|
||||
return;
|
||||
}
|
||||
patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
};
|
||||
|
||||
const syncControlledDocsAfterTask = async (taskKey: string, preferredDocId?: string | null) => {
|
||||
if (!task.projectId || task.projectId <= 0 || !taskKey) return;
|
||||
const { merged, serverDocId } = await pollUntilPostTaskServerDoc({
|
||||
listDocs: () => listProjectWorkflowDocuments(task.projectId!).catch(() => []),
|
||||
getDeliverables: () => deliverablesRef.current,
|
||||
merge: (prev, docs) =>
|
||||
mergeWorkflowDocumentsFromServer(prev, docs, task.projectId!, currentUser),
|
||||
projectId: task.projectId,
|
||||
taskKey,
|
||||
preferredDocId,
|
||||
maxAttempts: 10,
|
||||
delayMs: 800,
|
||||
onMerged: (next) => {
|
||||
deliverablesRef.current = next;
|
||||
setDeliverables(next);
|
||||
applyControlledInvalidationPlan(
|
||||
planControlledContentInvalidation({
|
||||
reason: 'post_task',
|
||||
candidateIds: controlledHydrateCandidateIds(next, task.projectId),
|
||||
activeDocId: activeDocIdRef.current,
|
||||
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (
|
||||
serverDocId &&
|
||||
shouldSwitchTemplateSlotToServer({
|
||||
activeDocId,
|
||||
deliverables: merged,
|
||||
projectId: task.projectId,
|
||||
taskKey,
|
||||
serverDocId,
|
||||
})
|
||||
) {
|
||||
setActiveDocId(serverDocId);
|
||||
setActiveCategory('workflow');
|
||||
}
|
||||
};
|
||||
|
||||
// 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条
|
||||
patchAsst({
|
||||
status: 'pending',
|
||||
livePhaseLabel:
|
||||
mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…',
|
||||
errorInfo: undefined,
|
||||
...(mode === 'resume' || attemptIndex > 0
|
||||
? {}
|
||||
: { content: '', phaseEvents: [], taskWorkspaceId: undefined }),
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, true);
|
||||
|
||||
const onEvent = (ev: StreamEvent) => {
|
||||
if (controller.signal.aborted || settled) return;
|
||||
switch (ev.type) {
|
||||
case 'phase': {
|
||||
patchAsst((m) => ({
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: ev.phase?.message || m.livePhaseLabel,
|
||||
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
if (ev.status?.message) {
|
||||
patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delta': {
|
||||
accumulated += ev.delta || '';
|
||||
patchAsst({ status: 'streaming_status', content: accumulated });
|
||||
break;
|
||||
}
|
||||
case 'deliverable': {
|
||||
const title = ev.deliverable?.title;
|
||||
if (title) {
|
||||
accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`;
|
||||
patchAsst({ status: 'streaming_status', content: accumulated });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'governance': {
|
||||
const governance = ev.governance;
|
||||
const docId = governance?.doc_id;
|
||||
if (docId) {
|
||||
const leaseReleased = governance.action === 'lease_released';
|
||||
const editLease = leaseReleased
|
||||
? undefined
|
||||
: { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined };
|
||||
setDeliverables((prev) =>
|
||||
prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)),
|
||||
);
|
||||
setDocCache((prev) => {
|
||||
const ast = prev[docId];
|
||||
return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev;
|
||||
});
|
||||
}
|
||||
if (governance?.notification_msg) {
|
||||
patchAsst({
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: governance.notification_msg,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
failOrRetry(mapErrorInfo(ev));
|
||||
break;
|
||||
}
|
||||
case 'done': {
|
||||
settled = true;
|
||||
const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId;
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
void (async () => {
|
||||
let nextStatus: AssistantMessageStatus = 'completed';
|
||||
let preferredDocId: string | null = null;
|
||||
if (finishedRunId) {
|
||||
const remote = await getTaskRun(finishedRunId).catch(() => null);
|
||||
if (remote?.status) {
|
||||
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant');
|
||||
if (mapped) nextStatus = mapped;
|
||||
}
|
||||
}
|
||||
patchAsst({
|
||||
status: nextStatus,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: ev.done?.workspace_id,
|
||||
...(finishedRunId ? { taskRunId: finishedRunId } : {}),
|
||||
});
|
||||
// quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换
|
||||
if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') {
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId);
|
||||
}
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
})();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const startObserver = async () => {
|
||||
const body =
|
||||
mode === 'resume' && opts?.workDir
|
||||
? buildResumeRequest(task, opts.workDir)
|
||||
: buildTaskRequest(task);
|
||||
|
||||
if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') {
|
||||
// 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId
|
||||
const existingRunId = resolveReconnectRunId({
|
||||
attemptIndex,
|
||||
optsReconnectRunId: retainedRunId || opts?.reconnectRunId,
|
||||
});
|
||||
if (attemptIndex > 0 && existingRunId) {
|
||||
const remote = await getTaskRun(existingRunId).catch(() => null);
|
||||
if (remote && shouldSubscribeExistingRun(remote.status)) {
|
||||
retainedRunId = existingRunId;
|
||||
taskRunStream.setRunId(sessionId, existingRunId);
|
||||
patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' });
|
||||
await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal);
|
||||
return;
|
||||
}
|
||||
if (remote && isRemoteTaskTerminal(remote.status)) {
|
||||
settled = true;
|
||||
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed';
|
||||
patchAsst({
|
||||
status: mapped,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: existingRunId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
if (mapped === 'completed' || mapped === 'pending_author_confirmation') {
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createTaskRun({
|
||||
...body,
|
||||
session_id: task.sessionId,
|
||||
project_id: task.projectId,
|
||||
user_id: task.userId,
|
||||
client_turn_id: task.clientTurnId,
|
||||
user_message: task.userMessage,
|
||||
});
|
||||
retainedRunId = created.run_id;
|
||||
taskRunStream.setRunId(sessionId, created.run_id);
|
||||
patchAsst({ taskRunId: created.run_id });
|
||||
if (isBlockedGovernanceStatus(created.status)) {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'blocked_pending_governance',
|
||||
content: GOVERNANCE_BLOCKED_HINT,
|
||||
livePhaseLabel: undefined,
|
||||
taskRunId: created.run_id,
|
||||
});
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal);
|
||||
return;
|
||||
}
|
||||
|
||||
await streamTask(body, { onEvent }, controller.signal);
|
||||
};
|
||||
|
||||
void startObserver().catch(async (_err) => {
|
||||
if (settled) return;
|
||||
if (controller.signal.aborted) {
|
||||
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
const runId = retainedRunId || taskRunStream.activeTaskRunId || undefined;
|
||||
if (runId) {
|
||||
try {
|
||||
const remote = await getTaskRun(runId);
|
||||
if (remote.status === 'quality_failed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'quality_failed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (isBlockedGovernanceStatus(remote.status)) {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'blocked_pending_governance',
|
||||
content: GOVERNANCE_BLOCKED_HINT,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (remote.status === 'completed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'completed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
return;
|
||||
}
|
||||
if (remote.status === 'pending_author_confirmation') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'pending_author_confirmation',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (shouldSubscribeExistingRun(remote.status)) {
|
||||
// 原 run 仍在跑:重订阅,不 POST 新 run
|
||||
settled = false;
|
||||
retainedRunId = runId;
|
||||
patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId });
|
||||
await subscribeTaskRunEvents(runId, { onEvent }, controller.signal);
|
||||
return;
|
||||
}
|
||||
if (remote.status === 'failed' || remote.status === 'cancelled') {
|
||||
failOrRetry(buildNetworkErrorInfo());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to network retry path
|
||||
}
|
||||
}
|
||||
const httpStatus =
|
||||
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
|
||||
? Number((_err as { httpStatus?: number }).httpStatus)
|
||||
: undefined;
|
||||
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(': '),
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复)
|
||||
const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => {
|
||||
const target = messages.find((m) => m.id === messageId);
|
||||
if (!target) return;
|
||||
|
||||
// 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启
|
||||
if (target.taskRequest) {
|
||||
if (action === 'retry') {
|
||||
const userText = target.taskRequest.userMessage || '';
|
||||
const retryActive =
|
||||
deliverables.find((d) => d.docId === activeDocId) ||
|
||||
visibleDeliverables.find((d) => d.docId === activeDocId);
|
||||
const reResolved = resolveTaskTemplateForMessage(
|
||||
userText,
|
||||
currentProjectDeliverables,
|
||||
retryActive,
|
||||
);
|
||||
const retryTask = reResolved
|
||||
? {
|
||||
...target.taskRequest,
|
||||
taskKey: reResolved.taskKey,
|
||||
docId: reResolved.docId || target.taskRequest.docId,
|
||||
}
|
||||
: target.taskRequest;
|
||||
runTaskAttempt(retryTask, messageId, 'run', 0);
|
||||
} else if (action === 'continue') {
|
||||
const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId;
|
||||
if (!wsId) return;
|
||||
runTaskAttempt(target.taskRequest, messageId, 'resume', 0, {
|
||||
phaseEvents: target.phaseEvents,
|
||||
workDir: wsId,
|
||||
});
|
||||
} else {
|
||||
if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return;
|
||||
runTaskAttempt(target.taskRequest, messageId, 'restart', 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 闲聊路径(P1 行为不变:仅 retry)
|
||||
if (action !== 'retry') return;
|
||||
const sourceMsg = target.sourceUserMessageId
|
||||
? messages.find((m) => m.id === target.sourceUserMessageId)
|
||||
: undefined;
|
||||
const originalText = sourceMsg?.content?.trim();
|
||||
if (!originalText) return;
|
||||
runStreamAttempt(originalText, messageId, 0, {
|
||||
userMsgId: sourceMsg?.id,
|
||||
clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId,
|
||||
});
|
||||
};
|
||||
|
||||
return { runStreamAttempt, runTaskAttempt, handleErrorAction };
|
||||
}
|
||||
Reference in New Issue
Block a user