ai-query对接新版freesun-agent接口的分支
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1288 lines
49 KiB

/**
* 文件作用:
* 封装 SuperAgent 页面与 useStream 的唯一协议连接。
*
* 职责:
* 1. 暴露标准 messages、toolCalls、interrupts、values 投影和运行控制方法。
* 2. 提交用户消息、响应 interrupt、停止运行和仅断开订阅。
* 3. 订阅业务 custom channel,并通知容器刷新 workspace/sections。
*
* 不负责:
* 1. 创建业务 session、加载 workspace/sections 或管理业务会话。
* 2. 手写 SSE、调用旧聊天协议或维护独立恢复接口。
*
* 维护说明:
* Agent v2 不可用时只暴露错误,调用方不得回退 /api/chat 或 runs/stream。
*/
"use strict";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStream } from "@langchain/react";
import { ProtocolSseTransportAdapter } from "@langchain/langgraph-sdk";
import { ROOT_PUMP_CHANNELS } from "@langchain/langgraph-sdk/stream";
import { buildSuperAgentServiceUrl } from "../actions/api";
import {
getCompatibleToken,
getCompatibleUserId,
} from "../../../utils/authCompat";
const BUSINESS_CHANNELS = [
"custom",
"custom:generation",
"custom:section",
"custom:workspace",
];
const TERMINAL_INTERRUPT_STATUSES = new Set([
"confirmed",
"edited",
"completed",
"done",
"success",
"cancelled",
"canceled",
]);
const ACTIVE_TASK_STATUSES = new Set([
"queued",
"starting",
"started",
"running",
"pending",
"processing",
"generating",
"writing",
"active",
"streaming",
"resuming",
"awaiting_interrupt",
]);
const getInterruptStatus = (item = {}) => {
const payload = item?.value || item?.payload || {};
return String(
item.status ||
item.checkpointStatus ||
payload.checkpoint_status ||
payload.checkpointStatus ||
payload.status ||
""
).toLowerCase();
};
const isTerminalInterrupt = (item = {}) =>
TERMINAL_INTERRUPT_STATUSES.has(getInterruptStatus(item));
const getInterruptInteractionKey = (item = {}) => {
const payload = item?.value || item?.payload || item || {};
const interactionId = String(
payload.interaction_id ||
payload.interactionId ||
item.interaction_id ||
item.interactionId ||
""
).trim();
if (!interactionId) return "";
const revision = payload.revision ?? item.revision ?? "";
return `${interactionId}:${String(revision)}`;
};
const parseMessageContent = (content) => {
if (content && typeof content === "object") return content;
if (typeof content !== "string") return null;
try {
return JSON.parse(content);
} catch (_) {
return null;
}
};
const collectResolvedInterruptInteractions = (messages = []) => {
const resolvedInteractions = new Set();
(Array.isArray(messages) ? messages : []).forEach((message) => {
if (String(message?.type || "").toLowerCase() !== "tool") return;
const result = parseMessageContent(message.content);
const decisionType = String(result?.type || "").toLowerCase();
const interactionId = String(result?.interaction_id || "").trim();
const revision = result?.revision ?? "";
if (
interactionId &&
["approve", "edit", "reject"].includes(decisionType)
) {
resolvedInteractions.add(`${interactionId}:${String(revision)}`);
}
});
return resolvedInteractions;
};
const isSuperAgentEventRequest = (input) => {
const requestUrl = String(input?.url || input || "");
return /\/threads\/[^/]+\/stream\/events(?:\?|$)/.test(requestUrl);
};
const isUseStreamHistoryDiscoveryRequest = (input) => {
const requestUrl = String(input?.url || input || "");
return /\/(?:threads|tasks)\/[^/]+\/history(?:\?|$)/.test(requestUrl);
};
const isLifecycleWatcherRequest = (input, init = {}) => {
if (!isSuperAgentEventRequest(input) || typeof init.body !== "string") {
return false;
}
try {
const requestBody = JSON.parse(init.body);
const channels = Array.isArray(requestBody?.channels)
? requestBody.channels
: [];
return (
channels.length === 2 &&
channels.includes("lifecycle") &&
channels.includes("input") &&
!Object.prototype.hasOwnProperty.call(requestBody, "namespaces") &&
!Object.prototype.hasOwnProperty.call(requestBody, "depth")
);
} catch {
return false;
}
};
const isLifecycleWatcherFilter = (params = {}) => {
const channels = Array.isArray(params.channels) ? params.channels : [];
return (
channels.length === 2 &&
channels.includes("lifecycle") &&
channels.includes("input") &&
!Object.prototype.hasOwnProperty.call(params, "namespaces") &&
!Object.prototype.hasOwnProperty.call(params, "depth")
);
};
const getEventRetryDelay = (response, attempt) => {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
return Math.min(Math.max(seconds * 1000, 100), 10000);
}
const retryAt = Date.parse(retryAfter);
if (Number.isFinite(retryAt)) {
return Math.min(Math.max(retryAt - Date.now(), 100), 10000);
}
}
return Math.min(250 * 2 ** Math.max(attempt - 1, 0), 4000);
};
const waitForEventRetry = (delay, signal) => {
if (signal?.aborted) {
return Promise.reject(signal.reason || new Error("事件流连接已中止"));
}
return new Promise((resolve, reject) => {
let timer = null;
const handleAbort = () => {
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", handleAbort);
reject(signal.reason || new Error("事件流连接已中止"));
};
timer = setTimeout(() => {
signal?.removeEventListener("abort", handleAbort);
resolve();
}, delay);
signal?.addEventListener("abort", handleAbort, { once: true });
});
};
const EVENT_STREAM_RELEASE_WAIT_MS = 300;
const activeEventStreamsByThread = new Map();
const eventStreamCloseAtByThread = new Map();
const waitForEventStreamRelease = async (threadId, signal) => {
const closedAt = eventStreamCloseAtByThread.get(threadId);
if (!closedAt) return;
const elapsed = Date.now() - closedAt;
const remaining = EVENT_STREAM_RELEASE_WAIT_MS - elapsed;
if (remaining > 0) {
await waitForEventRetry(remaining, signal);
}
if (eventStreamCloseAtByThread.get(threadId) === closedAt) {
eventStreamCloseAtByThread.delete(threadId);
}
};
// 服务端释放旧 subscriber 需要一点时间。SDK 默认会在 429 后直接进入
// 固定次数的重连,耗尽后把 ThreadStream 标记为失败;这里按 Retry-After
// 在 fetch 层恢复,避免一次连接竞争让整个会话进入不可恢复状态。
const fetchEventStreamWithRecovery = async (input, init) => {
const max429Retries = 10;
let attempt = 0;
while (true) {
const response = await fetch(input, init);
if (response.status !== 429 || !isSuperAgentEventRequest(input)) {
return response;
}
if (attempt >= max429Retries || init?.signal?.aborted) {
return response;
}
attempt += 1;
if (response.body) {
await response.body.cancel().catch(() => undefined);
}
await waitForEventRetry(getEventRetryDelay(response, attempt), init?.signal);
}
};
//[单订阅 SSE]当前 Agent 服务同一 thread 只允许一个 /events subscriber。
//SDK 默认 rotation 是先开新连接、再关旧连接,会在服务端触发 429;这里改为先关旧再开新。
class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter {
activeEventStream = null;
eventHandles = new Set();
latestState = null;
onStateChange = null;
stateRequestVersion = 0;
async getState() {
const requestVersion = ++this.stateRequestVersion;
const requestedThreadId = String(this.threadId || "").trim();
const state = await super.getState();
// getState 可能在切换 thread 后才返回;旧请求不能覆盖新 thread 的快照。
if (
requestVersion === this.stateRequestVersion &&
String(this.threadId || "").trim() === requestedThreadId
) {
this.latestState = state;
this.onStateChange?.();
}
return state;
}
openEventStream(params) {
const isLifecycleWatcher = isLifecycleWatcherFilter(params);
const threadId = String(this.threadId || "").trim();
if (isLifecycleWatcher) {
// useStream 的 root pump 已经覆盖 lifecycle/input,额外 watcher
// 不参与主事件流的连接排他和释放等待。
return super.openEventStream(params);
}
const previousEventStream = activeEventStreamsByThread.get(threadId);
previousEventStream?.close();
this.activeEventStream?.close();
this.activeEventStream = null;
let innerHandle = null;
let isClosed = false;
let eventIterator = null;
const createStartPromise = () => (async () => {
await waitForEventStreamRelease(threadId);
if (isClosed) throw new Error("事件流连接已取消");
innerHandle = super.openEventStream(params);
return innerHandle;
})();
let startPromise = createStartPromise();
const handle = {
ready: startPromise.then((nextHandle) => nextHandle.ready),
events: {
[Symbol.asyncIterator]: () => ({
next: async () => {
while (!isClosed) {
if (!eventIterator) {
const nextHandle = await startPromise;
eventIterator = nextHandle.events[Symbol.asyncIterator]();
}
const nextEvent = await eventIterator.next();
if (!nextEvent.done) return nextEvent;
// 服务端可能在一轮运行结束后主动关闭 SSE。保持外层
// handle 不结束,下一轮提交时重新建立同一 thread 的事件流。
innerHandle?.close();
innerHandle = null;
eventIterator = null;
if (!isClosed) {
await waitForEventRetry(100);
startPromise = createStartPromise();
}
}
return { done: true, value: undefined };
},
return: async () => {
handle.close();
if (eventIterator?.return) await eventIterator.return();
return { done: true, value: undefined };
},
}),
},
close: () => {
if (isClosed) return;
isClosed = true;
eventStreamCloseAtByThread.set(threadId, Date.now());
if (activeEventStreamsByThread.get(threadId) === handle) {
activeEventStreamsByThread.delete(threadId);
}
if (this.activeEventStream === handle) this.activeEventStream = null;
this.eventHandles.delete(handle);
innerHandle?.close();
},
};
this.eventHandles.add(handle);
this.activeEventStream = handle;
activeEventStreamsByThread.set(threadId, handle);
return handle;
}
// ThreadStream 会在切换 thread 时调用 close,但同一个 adapter 会被 SDK 复用;
// 这里只关闭连接,不调用父类 close(父类会把 transport 永久标记为 closed)。
close() {
for (const handle of this.eventHandles) handle.close();
this.eventHandles.clear();
this.activeEventStream = null;
}
}
const mergeAbortSignals = (signals = []) => {
const validSignals = signals.filter(Boolean);
if (!validSignals.length) return undefined;
if (validSignals.length === 1) return validSignals[0];
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
return AbortSignal.any(validSignals);
}
const controller = new AbortController();
const abort = () => controller.abort();
validSignals.forEach((signal) => {
if (signal.aborted) {
abort();
return;
}
signal.addEventListener("abort", abort, { once: true });
});
return controller.signal;
};
// 业务事件复用 useStream 已有的 root pump,不再额外建立页面级事件连接。
// 将业务 channel 纳入 root pump,避免 useChannelEffect 为 custom channel
// 触发 SSE rotation,导致新旧 /events 短暂重叠并被服务端返回 429。
BUSINESS_CHANNELS.forEach((channel) => {
if (!ROOT_PUMP_CHANNELS.includes(channel)) ROOT_PUMP_CHANNELS.push(channel);
});
const createClientMessageId = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `client-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const getEventPayload = (event) =>
event?.data ??
event?.payload ??
event?.params?.data?.payload ??
event?.params?.data ??
event;
const getEventDedupeKey = (event) => {
if (event?.event_id) return String(event.event_id);
const payload = getEventPayload(event) || {};
return [
payload.task_id || payload.taskId || "",
payload.node_id || payload.nodeId || "",
payload.version || "",
payload.type || event?.type || event?.event || "",
payload.progress ?? payload.task?.progress ?? "",
payload.done ?? payload.task?.done ?? "",
payload.section_id || payload.sectionId || payload.task?.section_id || "",
].join(":");
};
const normalizeStreamError = (error) => {
const status = error?.status || error?.response?.status;
if (status === 401 || status === 403) return new Error("登录状态已失效,请重新登录");
if (status === 404) return new Error("智能体服务暂不可用");
const message = String(error?.message || "");
if (status === 429 || /(?:^|\D)429(?:\D|$)|too many requests|rate limit/i.test(message)) {
return new Error("事件流连接繁忙,正在自动恢复,请稍候");
}
if (/sse|stream|network|fetch/i.test(message)) return new Error("智能体流式连接异常");
return error instanceof Error ? error : new Error("智能体协议响应错误");
};
const getSuperAgentApiUrl = () => buildSuperAgentServiceUrl("/api/v1");
const useSuperAgentStream = ({
activeSessionId,
userId: sessionUserId,
onBusinessInvalidation,
onStreamError,
}) => {
const handledEventKeysRef = useRef(new Set());
const interruptResumeRef = useRef({ active: false, running: false });
const pendingInterruptRef = useRef(false);
const awaitingInterruptRef = useRef(false);
const [runPhase, setRunPhase] = useState("idle");
const [isLocalRunActive, setIsLocalRunActive] = useState(false);
const [isRespondingInterrupt, setIsRespondingInterrupt] = useState(false);
const [isBusinessRunning, setIsBusinessRunning] = useState(false);
const [runtimeCheckpointDeliveries, setRuntimeCheckpointDeliveries] = useState([]);
const [runtimeInterrupts, setRuntimeInterrupts] = useState([]);
const [dismissedInterruptIds, setDismissedInterruptIds] = useState(new Set());
const resolvedInterruptIdsRef = useRef(new Set());
const resolvedInterruptInteractionsRef = useRef(new Set());
const [currentInterruptId, setCurrentInterruptId] = useState("");
const [hydratedThreadId, setHydratedThreadId] = useState("");
const [transportStateRevision, setTransportStateRevision] = useState(0);
const eventRequestControllersRef = useRef(new Map());
const pendingSubmitThreadIdRef = useRef("");
const hydrationSessionRef = useRef({
sessionId: "",
hasSeenLoading: false,
});
const localRunActiveRef = useRef(false);
const localRunHasSeenLoadingRef = useRef(false);
const activeSessionIdRef = useRef("");
activeSessionIdRef.current = activeSessionId;
const token = getCompatibleToken();
const userId = sessionUserId || getCompatibleUserId();
// 保持身份头稳定;会话切换由 callerOptions 的会话级 fetch 变化触发
// 新 controller,同时先中止旧会话的 /events 请求。
const defaultHeaders = useMemo(
() => ({
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(token ? { "X-Search-Token": token } : {}),
...(userId ? { "X-User-Id": String(userId) } : {}),
}),
[token, userId]
);
const fetchUseStreamTransport = useCallback(async (...args) => {
const [input, init = {}] = args;
if (isUseStreamHistoryDiscoveryRequest(input)) {
// 当前 SDK hydrate 会为 namespace discovery 自动读取 history。
// SuperAgent 只以 thread state 和 event stream 为准,禁止该
// 非业务读取落到服务端,避免 history 再次参与会话恢复。
return new Response("[]", {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (isLifecycleWatcherRequest(input, init)) {
// Agent 服务每个 thread 只允许一个 events subscriber。useStream
// 的 root pump 已覆盖 lifecycle/input,额外 watcher 会稳定触发 429。
return new Response(null, {
status: 204,
headers: { "content-type": "text/event-stream" },
});
}
let requestInit = init;
const sessionKey = String(
pendingSubmitThreadIdRef.current || activeSessionIdRef.current || ""
).trim();
if (sessionKey && isSuperAgentEventRequest(input)) {
let sessionController = eventRequestControllersRef.current.get(sessionKey);
if (!sessionController) {
sessionController = new AbortController();
eventRequestControllersRef.current.set(sessionKey, sessionController);
}
requestInit = {
...init,
signal: mergeAbortSignals([init.signal, sessionController.signal]),
};
}
return fetchEventStreamWithRecovery(input, requestInit);
}, []);
const eventTransport = useMemo(
() => {
const transport = new SingleSubscriberSseTransportAdapter({
apiUrl: getSuperAgentApiUrl(),
defaultHeaders,
fetch: fetchUseStreamTransport,
maxReconnectAttempts: 5,
});
transport.onStateChange = () => {
setTransportStateRevision((revision) => revision + 1);
};
return transport;
},
[activeSessionId, defaultHeaders, fetchUseStreamTransport]
);
// StreamController 在 hydrate 时会先通过 transport.getState() 取新 thread;
// 自定义 adapter 需在该 effect 运行前同步绑定目标 thread,避免读到旧会话。
eventTransport.setThreadId(
activeSessionId || pendingSubmitThreadIdRef.current || ""
);
const callerOptions = useMemo(
() => ({ fetch: fetchUseStreamTransport }),
[fetchUseStreamTransport]
);
const stream = useStream({
assistantId: "freesun_agent",
apiUrl: getSuperAgentApiUrl(),
threadId: activeSessionId || null,
defaultHeaders,
callerOptions,
messagesKey: "messages",
optimistic: true,
transport: eventTransport,
});
const handleBusinessEvent = useCallback((event) => {
const eventKey = getEventDedupeKey(event);
if (handledEventKeysRef.current.has(eventKey)) return;
handledEventKeysRef.current.add(eventKey);
const payload = getEventPayload(event) || {};
const eventType = String(payload.type || "");
if ([
"generation.started",
"generation.progress",
"section.started",
"section.progress",
"section.updated",
"sections.updated",
"workspace.updated",
"sections.rewrite.requested",
].includes(eventType)) {
setIsBusinessRunning(true);
awaitingInterruptRef.current = false;
setRunPhase("running");
}
if ([
"generation.completed",
"generation.failed",
"generation.cancelled",
"generation.error",
].includes(eventType)) {
setIsBusinessRunning(false);
}
const envelope = payload.envelope;
if (payload.type === "workspace.snapshot" && envelope?.checkpoint_id) {
setRuntimeCheckpointDeliveries((current) => {
const next = current.filter(
(item) => item?.checkpoint_id !== envelope.checkpoint_id
);
return [...next, envelope];
});
}
onBusinessInvalidation?.(payload, event);
}, [onBusinessInvalidation]);
const handleStreamError = useCallback((error) => {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false;
awaitingInterruptRef.current = false;
setRunPhase("failed");
setIsRespondingInterrupt(false);
setIsBusinessRunning(false);
onStreamError?.(normalizeStreamError(error));
}, [onStreamError]);
useEffect(() => {
const sessionKey = String(activeSessionId || "").trim();
return () => {
if (sessionKey) {
const sessionController = eventRequestControllersRef.current.get(sessionKey);
sessionController?.abort();
eventRequestControllersRef.current.delete(sessionKey);
}
// SDK 的 stop/disconnect 不一定会销毁自定义 transport 的旧 handle,
// 卸载或切换会话时必须主动关闭,确保后端释放对应 subscriber。
eventTransport.close();
};
}, [activeSessionId, eventTransport]);
useEffect(() => {
if (pendingSubmitThreadIdRef.current === String(activeSessionId || "")) {
pendingSubmitThreadIdRef.current = "";
}
hydrationSessionRef.current = {
sessionId: String(activeSessionId || ""),
hasSeenLoading: false,
};
handledEventKeysRef.current.clear();
localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false;
awaitingInterruptRef.current = false;
setRunPhase("idle");
setIsRespondingInterrupt(false);
setIsBusinessRunning(false);
setRuntimeCheckpointDeliveries([]);
setRuntimeInterrupts([]);
setDismissedInterruptIds(new Set());
resolvedInterruptIdsRef.current.clear();
resolvedInterruptInteractionsRef.current.clear();
setCurrentInterruptId("");
setHydratedThreadId("");
}, [activeSessionId]);
useEffect(() => {
const sessionId = String(activeSessionId || "");
const threadId = String(stream.threadId || "");
if (!sessionId || sessionId !== threadId) return undefined;
let cancelled = false;
// isThreadLoading 在 useStream hydrate 切换的中间阶段可能仍是 false,
// 不能用它作为快照完成标志;hydrationPromise 才代表当前 thread 的 state
// 已经写入 root store。
Promise.resolve(stream.hydrationPromise)
.then(() => {
if (cancelled || activeSessionIdRef.current !== sessionId) return;
setHydratedThreadId(sessionId);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [
activeSessionId,
stream.hydrationPromise,
stream.threadId,
transportStateRevision,
]);
const checkpointDeliveries = useMemo(() => {
const deliveries = new Map();
const hydratedDeliveries = [
...(Array.isArray(stream.values?.checkpoint_deliveries)
? stream.values.checkpoint_deliveries
: []),
...(Array.isArray(eventTransport.latestState?.values?.checkpoint_deliveries)
? eventTransport.latestState.values.checkpoint_deliveries
: []),
];
[...hydratedDeliveries, ...runtimeCheckpointDeliveries].forEach((item) => {
if (!item?.checkpoint_id) return;
deliveries.set(item.checkpoint_id, item);
});
return [...deliveries.values()];
}, [
eventTransport.latestState,
runtimeCheckpointDeliveries,
stream.values?.checkpoint_deliveries,
transportStateRevision,
]);
useEffect(() => {
if (stream.error) handleStreamError(stream.error);
}, [handleStreamError, stream.error]);
useEffect(() => {
if (!localRunActiveRef.current) return;
if (stream.isLoading) {
localRunHasSeenLoadingRef.current = true;
return;
}
if (!localRunHasSeenLoadingRef.current) return;
localRunActiveRef.current = false;
localRunHasSeenLoadingRef.current = false;
setIsLocalRunActive(false);
setRunPhase((currentPhase) =>
["running", "resuming"].includes(currentPhase) ? "completed" : currentPhase
);
setIsBusinessRunning(false);
setIsRespondingInterrupt(false);
}, [stream.isLoading]);
useEffect(() => {
const isCurrentThread =
Boolean(activeSessionId) &&
String(stream.threadId || "") === String(activeSessionId || "");
if (!isCurrentThread) return undefined;
const listenerSessionId = String(activeSessionId || "");
const currentThread = stream.getThread?.();
if (!currentThread) return undefined;
return currentThread.onEvent((event) => {
// 切换会话后旧 Thread 的监听器可能在清理前再收到一个事件,
// 不能把旧会话的事件写入新会话的流式消息状态。
if (activeSessionIdRef.current !== listenerSessionId) return;
if (event?.method === "input.requested") {
const data = event.params?.data || {};
const interruptId = String(data.interrupt_id || "").trim();
const interactionKey = getInterruptInteractionKey(data.payload);
const isResolvedInterrupt =
resolvedInterruptIdsRef.current.has(interruptId) ||
(interactionKey &&
resolvedInterruptInteractionsRef.current.has(interactionKey));
if (interruptId && !isResolvedInterrupt) {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
setCurrentInterruptId(interruptId);
setRuntimeInterrupts((currentInterrupts) => {
const nextInterrupts = currentInterrupts.filter(
(item) => String(item?.id || "") !== interruptId
);
return [
...nextInterrupts,
{
id: interruptId,
value: data.payload,
namespace: Array.isArray(event.params?.namespace)
? event.params.namespace
: [],
},
];
});
pendingInterruptRef.current = true;
awaitingInterruptRef.current = false;
setRunPhase("waiting_interrupt");
}
}
if (event?.method === "lifecycle" && Array.isArray(event?.params?.namespace) && event.params.namespace.length === 0) {
const lifecycleEvent = event.params.data?.event;
if (["started", "running"].includes(lifecycleEvent)) {
pendingInterruptRef.current = false;
awaitingInterruptRef.current = false;
setRunPhase("running");
}
if (lifecycleEvent === "running" && interruptResumeRef.current.active) {
interruptResumeRef.current.running = true;
}
if (lifecycleEvent === "interrupted") {
const threadInterrupts = currentThread.interrupts || [];
const hasRealInterrupt =
pendingInterruptRef.current ||
stream.interrupts.length > 0 ||
threadInterrupts.length > 0;
if (hasRealInterrupt) {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
awaitingInterruptRef.current = false;
setRunPhase("waiting_interrupt");
} else {
// interrupted 可能早于 input.requested 到达,不能将本轮误判为完成。
awaitingInterruptRef.current = true;
setRunPhase("awaiting_interrupt");
}
}
if (["completed", "failed", "aborted"].includes(lifecycleEvent)) {
if (pendingInterruptRef.current) {
setRunPhase("waiting_interrupt");
} else {
awaitingInterruptRef.current = false;
localRunActiveRef.current = false;
setIsLocalRunActive(false);
// aborted 表示本轮已结束,不能让 started 留下的 running 状态继续生效。
setRunPhase(lifecycleEvent === "aborted" ? "completed" : lifecycleEvent);
}
setIsBusinessRunning(false);
if (interruptResumeRef.current.active) {
interruptResumeRef.current = { active: false, running: false };
setIsRespondingInterrupt(false);
}
}
}
if (!BUSINESS_CHANNELS.includes(event?.method)) return;
handleBusinessEvent(event);
});
}, [activeSessionId, handleBusinessEvent, stream.threadId]);
const prepareForSessionSwitch = useCallback(() => {
// 先让旧 thread 的事件监听失效。activeSessionId 在 React 下一次渲染
// 前仍可能是旧值,单靠监听器里的 sessionId 判断不够。
activeSessionIdRef.current = "";
// pendingSubmitThreadId 只服务于“创建会话后首次发送”这一瞬间;
// 切换会话时必须清掉,否则新 transport 会继续绑定旧 thread。
pendingSubmitThreadIdRef.current = "";
setHydratedThreadId("");
localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false;
awaitingInterruptRef.current = false;
setRunPhase("idle");
setIsRespondingInterrupt(false);
setIsBusinessRunning(false);
setRuntimeInterrupts([]);
resolvedInterruptIdsRef.current.clear();
resolvedInterruptInteractionsRef.current.clear();
// 会话切换只断开当前浏览器订阅,不能发送 stop/cancel 命令。
// stopRun 只由用户点击停止按钮触发,否则后端会把原会话改为 suspended。
// useStream 切换 threadId 时会等待旧 root pump 清理;直接关闭当前
// ThreadStream 可以立即中止旧 /events 请求,避免阻塞新会话接管。
const currentThread = stream.getThread?.();
eventTransport.close();
return Promise.allSettled([
stream.disconnect(),
currentThread ? currentThread.close() : Promise.resolve(),
]);
}, [eventTransport, stream]);
const submitMessage = useCallback(async ({
content,
displayContent,
attachmentIds = [],
threadId,
//[中断恢复后的直接输入允许进入 SDK 队列,等待当前恢复运行结束后再发送]
allowDuringInterruptResponse = false,
multitaskStrategy = "reject",
}) => {
const targetThreadId = String(threadId || activeSessionId || "").trim();
if (!targetThreadId) throw new Error("请先创建任务");
const isBlockedByInterrupt = isRespondingInterrupt && !allowDuringInterruptResponse;
const isSwitchingThread = String(stream.threadId || "") !== targetThreadId;
// isThreadLoading 只表示当前 thread 的快照还在 hydrate,不能阻止首条消息提交。
// 真正表示本轮任务仍在运行的是 isLoading;否则新建会话会先创建成功,
// 但首条消息被这里提前拒绝,页面最终只剩空会话。
if ((stream.isLoading && !isSwitchingThread) || isBlockedByInterrupt) {
throw new Error("任务正在处理,请稍后再试");
}
if (stream.isThreadLoading && !isSwitchingThread) {
// 新建会话的 state 可能还未写入 root store。等待 hydrate 完成后再提交,
// 避免 optimistic 消息被空快照或旧事件回放覆盖。
await Promise.resolve(stream.hydrationPromise).catch(() => undefined);
}
// activeSessionId 的 React 状态更新可能晚于首次提交;先绑定 transport
// 和事件请求所属会话,避免首轮响应仍落到旧 thread。
if (hydrationSessionRef.current.sessionId === targetThreadId) {
hydrationSessionRef.current.hasSeenLoading = true;
}
pendingSubmitThreadIdRef.current = targetThreadId;
eventTransport.setThreadId(targetThreadId);
localRunActiveRef.current = true;
setIsLocalRunActive(true);
localRunHasSeenLoadingRef.current = false;
setRunPhase("running");
try {
await stream.submit({
messages: [{
type: "human",
id: createClientMessageId(),
content: String(content || ""),
}],
display_content: String(displayContent ?? content ?? ""),
attachment_ids: Array.isArray(attachmentIds) ? attachmentIds : [],
}, {
threadId: targetThreadId,
multitaskStrategy,
metadata: { source: "super-agent-web" },
});
} catch (error) {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
throw error;
}
}, [activeSessionId, eventTransport, isRespondingInterrupt, stream]);
const respondInterrupt = useCallback((response, options = {}) => {
const interruptId = String(options.interruptId || "").trim();
localRunActiveRef.current = true;
setIsLocalRunActive(true);
localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: true, running: false };
pendingInterruptRef.current = false;
awaitingInterruptRef.current = false;
setRunPhase("resuming");
setIsRespondingInterrupt(true);
if (interruptId) {
// 提交恢复请求后先隐藏旧卡片,避免响应流期间旧 interrupt 被重复渲染。
setDismissedInterruptIds((current) => {
const next = new Set(current);
next.add(interruptId);
return next;
});
setCurrentInterruptId((currentId) =>
currentId === interruptId ? "" : currentId
);
}
return stream.respond(response, {
interruptId: options.interruptId,
namespace: options.namespace,
}).then((result) => {
if (interruptId) {
resolvedInterruptIdsRef.current.add(interruptId);
const interactionKey = getInterruptInteractionKey(
response?.value || response?.payload || response
);
if (interactionKey) {
resolvedInterruptInteractionsRef.current.add(interactionKey);
}
setRuntimeInterrupts((currentInterrupts) =>
currentInterrupts.filter(
(item) => String(item?.id || "") !== interruptId
)
);
}
// respond 只代表恢复命令已提交,不代表后端本轮运行完成。
// 处理中状态由 lifecycle completed/failed/interrupted 统一释放。
return result;
}).catch((error) => {
if (interruptId) {
// 恢复失败时撤销临时隐藏,让用户可以重新提交。
resolvedInterruptIdsRef.current.delete(interruptId);
setDismissedInterruptIds((current) => {
const next = new Set(current);
next.delete(interruptId);
return next;
});
setCurrentInterruptId((currentId) => currentId || interruptId);
}
interruptResumeRef.current = { active: false, running: false };
localRunActiveRef.current = false;
setIsLocalRunActive(false);
pendingInterruptRef.current = true;
setRunPhase("waiting_interrupt");
setIsRespondingInterrupt(false);
throw error;
});
}, [stream]);
const stopRun = useCallback(async () => {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false;
awaitingInterruptRef.current = false;
setRunPhase("idle");
setIsRespondingInterrupt(false);
setIsBusinessRunning(false);
await stream.stop();
}, [stream]);
const currentSessionId = String(activeSessionId || "");
const currentThreadId = String(stream.threadId || "");
const projectedThreadId = String(
pendingSubmitThreadIdRef.current || currentThreadId
);
const isThreadBound =
Boolean(currentSessionId) && currentSessionId === currentThreadId;
const isProjectedThreadBound =
Boolean(currentSessionId) && currentSessionId === projectedThreadId;
if (hydrationSessionRef.current.sessionId !== currentSessionId) {
hydrationSessionRef.current = {
sessionId: currentSessionId,
hasSeenLoading: false,
};
}
if (isThreadBound && stream.isThreadLoading) {
hydrationSessionRef.current.hasSeenLoading = true;
}
const isThreadReady =
isThreadBound &&
!stream.isThreadLoading &&
hydratedThreadId === currentSessionId;
const isPendingSubmitThread =
isProjectedThreadBound &&
pendingSubmitThreadIdRef.current === currentSessionId;
const isStreamProjectionReady =
isThreadReady || isPendingSubmitThread;
const isInterruptProjectionReady =
isThreadBound && runtimeInterrupts.length > 0;
const isCurrentThread = isThreadReady;
const hydratedStateThreadId = String(
eventTransport.latestState?.checkpoint?.thread_id ||
eventTransport.latestState?.checkpoint?.threadId ||
eventTransport.latestState?.values?.task?.task_id ||
eventTransport.latestState?.values?.task?.taskId ||
""
);
const hydratedState =
isThreadReady &&
hydratedStateThreadId === currentSessionId
? eventTransport.latestState
: null;
const rawStreamMessages = isStreamProjectionReady ? stream.messages : [];
const rawStreamValues = isStreamProjectionReady ? stream.values || {} : {};
const hydratedMessages = Array.isArray(hydratedState?.values?.messages)
? hydratedState.values.messages
: [];
const historicalResolvedInteractionKeys = useMemo(
() =>
collectResolvedInterruptInteractions([
...hydratedMessages,
...rawStreamMessages,
]),
[hydratedMessages, rawStreamMessages]
);
useEffect(() => {
historicalResolvedInteractionKeys.forEach((interactionKey) => {
resolvedInterruptInteractionsRef.current.add(interactionKey);
});
}, [historicalResolvedInteractionKeys]);
// 只有 hydrate 真正拿到消息后,才能解除首次提交的 thread 投影保护。
// 仅有 task 快照不代表消息已完成落库,否则 task 先到时会让当前消息再次被隐藏。
const hasHydratedSessionContent = hydratedMessages.length > 0;
useEffect(() => {
if (!isThreadReady || !isPendingSubmitThread || !hasHydratedSessionContent) return;
pendingSubmitThreadIdRef.current = "";
}, [
hasHydratedSessionContent,
isPendingSubmitThread,
isThreadReady,
]);
const hydratedTailIds = hydratedMessages
.slice(-3)
.map((item) => String(item?.id || "").trim())
.filter(Boolean);
const rawStreamMessageIds = new Set(
rawStreamMessages
.map((item) => String(item?.id || "").trim())
.filter(Boolean)
);
const hydratedTaskStatus = String(
hydratedState?.values?.status ||
hydratedState?.values?.session_status ||
hydratedState?.values?.sessionStatus ||
hydratedState?.values?.task?.session_status ||
hydratedState?.values?.task?.sessionStatus ||
hydratedState?.values?.task?.status ||
""
).toLowerCase();
const rawTaskStatus = String(
rawStreamValues.status ||
rawStreamValues.session_status ||
rawStreamValues.sessionStatus ||
rawStreamValues.task?.session_status ||
rawStreamValues.task?.sessionStatus ||
rawStreamValues.task?.status ||
""
).toLowerCase();
const hydratedCheckpointDeliveryCount = Array.isArray(
hydratedState?.values?.checkpoint_deliveries
)
? hydratedState.values.checkpoint_deliveries.length
: 0;
const rawCheckpointDeliveryCount = Array.isArray(rawStreamValues.checkpoint_deliveries)
? rawStreamValues.checkpoint_deliveries.length
: 0;
const shouldPreferHydratedRuntimeState =
Boolean(hydratedState) &&
Boolean(hydratedTaskStatus || hydratedCheckpointDeliveryCount) &&
(!rawTaskStatus && !rawCheckpointDeliveryCount ||
hydratedTaskStatus !== rawTaskStatus ||
hydratedCheckpointDeliveryCount > rawCheckpointDeliveryCount);
//[state 是当前 thread 的最新快照,SSE 重连可能先重放更早的 values]
// 消息和 task/value 的权威来源不能共用一个切换条件。
// hydrate 返回空 messages 时,仍可能带有较新的 task 状态;此时只能更新
// task/value,不能让空快照覆盖事件流已经收到的消息。
const shouldPreferHydratedMessages =
hydratedMessages.length > 0 &&
(rawStreamMessages.length < hydratedMessages.length ||
(hydratedTailIds.length > 0 &&
!hydratedTailIds.every((messageId) => rawStreamMessageIds.has(messageId))));
const shouldPreferHydratedState =
shouldPreferHydratedRuntimeState || shouldPreferHydratedMessages;
const streamMessages = shouldPreferHydratedMessages
? hydratedMessages
: rawStreamMessages;
const streamToolCalls = isStreamProjectionReady ? stream.toolCalls : [];
const streamValues = shouldPreferHydratedState
? hydratedState?.values || {}
: rawStreamValues;
const streamSessionStatus = String(
streamValues.status ||
streamValues.session_status ||
streamValues.sessionStatus ||
streamValues.task?.session_status ||
streamValues.task?.sessionStatus ||
""
).toLowerCase();
const streamTask = streamValues?.task
? {
...streamValues.task,
...(streamSessionStatus
? { session_status: streamSessionStatus }
: {}),
}
: null;
const persistedTaskStatus = String(
streamSessionStatus || streamTask?.status || ""
).toLowerCase();
const isPersistedTaskActive = ACTIVE_TASK_STATUSES.has(persistedTaskStatus);
const hasPersistedNextNodes =
Array.isArray(hydratedState?.next) && hydratedState.next.length > 0;
const hydratedInterrupts = [
...(Array.isArray(hydratedState?.interrupts)
? hydratedState.interrupts
: []),
...(Array.isArray(hydratedState?.values?.__interrupt__)
? hydratedState.values.__interrupt__.map((item) => ({
...item,
id: item?.id || item?.interruptId,
value: item?.value ?? item?.payload,
namespace: Array.isArray(item?.namespace) ? item.namespace : [],
}))
: []),
];
const pendingHydratedInterrupts = hydratedInterrupts.filter((item) => {
const interruptId = String(item?.id || item?.interruptId || "").trim();
const interactionKey = getInterruptInteractionKey(item);
return (
!resolvedInterruptIdsRef.current.has(interruptId) &&
!historicalResolvedInteractionKeys.has(interactionKey)
);
});
const hasPersistedInterrupts = pendingHydratedInterrupts.length > 0;
const persistedInterrupts =
hydratedState
? pendingHydratedInterrupts
: isStreamProjectionReady
? stream.interrupts
: [];
const streamInterrupts = [
...(Array.isArray(persistedInterrupts) ? persistedInterrupts : []),
...runtimeInterrupts,
];
const shouldResetStaleWaitingPhase =
Boolean(hydratedState) &&
!hasPersistedInterrupts &&
["waiting_interrupt", "awaiting_interrupt"].includes(runPhase);
const isPersistedThreadActive =
isPersistedTaskActive || hasPersistedNextNodes || hasPersistedInterrupts;
const isPersistedExecutionActive =
isPersistedThreadActive &&
![
"suspended",
"waiting_user",
"awaiting_interrupt",
"waiting_interrupt",
].includes(persistedTaskStatus) &&
!hasPersistedInterrupts;
const hasRuntimeExecutionSignal =
localRunActiveRef.current ||
isLocalRunActive ||
isBusinessRunning ||
["running", "resuming"].includes(runPhase);
const isPersistedSuspended =
persistedTaskStatus === "suspended" &&
!hasPersistedInterrupts &&
!hasRuntimeExecutionSignal;
const isTerminalRunPhase = [
"completed",
"aborted",
"failed",
"cancelled",
"canceled",
].includes(runPhase);
const effectiveRunPhase =
isPersistedSuspended
? "idle"
: shouldResetStaleWaitingPhase
? "running"
: isPersistedExecutionActive && (runPhase === "idle" || isTerminalRunPhase)
? "running"
: runPhase;
const effectiveIsLocalRunActive = isPersistedSuspended ? false : isLocalRunActive;
const effectiveIsBusinessRunning = isPersistedSuspended ? false : isBusinessRunning;
const visibleInterrupts = useMemo(() => {
if (!isStreamProjectionReady && !isInterruptProjectionReady) return [];
const merged = new Map();
const appendInterrupt = (item, fallback = {}) => {
const id = String(item?.id || item?.interruptId || "").trim();
if (!id) return;
const previous = merged.get(id) || {};
merged.set(id, {
...previous,
...fallback,
...item,
id,
value: item?.value ?? item?.payload ?? previous.value,
namespace: Array.isArray(item?.namespace)
? item.namespace
: previous.namespace || [],
});
};
streamInterrupts.forEach((item) => appendInterrupt(item));
return [...merged.values()].filter(
(item) => {
const interruptId = String(item.id || "");
const interactionKey = getInterruptInteractionKey(item);
const isHistoricalResolved =
historicalResolvedInteractionKeys.has(interactionKey) ||
resolvedInterruptInteractionsRef.current.has(interactionKey);
return (
!dismissedInterruptIds.has(interruptId) &&
!resolvedInterruptIdsRef.current.has(interruptId) &&
!isHistoricalResolved &&
!isTerminalInterrupt(item)
);
}
);
}, [
dismissedInterruptIds,
isInterruptProjectionReady,
isStreamProjectionReady,
historicalResolvedInteractionKeys,
streamInterrupts,
]);
const isRunPhaseActive = ["running", "awaiting_interrupt", "resuming"].includes(
effectiveRunPhase
);
const effectiveIsLoading =
isPersistedSuspended
? isRespondingInterrupt
: stream.isLoading ||
isRespondingInterrupt ||
isBusinessRunning ||
isRunPhaseActive ||
isPersistedExecutionActive;
return {
messages: streamMessages,
toolCalls: streamToolCalls,
interrupt: visibleInterrupts[0] || null,
interrupts: visibleInterrupts,
values: streamValues,
checkpointDeliveries: isStreamProjectionReady ? checkpointDeliveries : [],
task: streamTask,
sessionStatus: streamSessionStatus,
isLoading: effectiveIsLoading,
isLocalRunActive: effectiveIsLocalRunActive,
runPhase: effectiveRunPhase,
isPersistedThreadActive,
currentInterruptId:
hydratedState &&
pendingHydratedInterrupts.length === 0 &&
runtimeInterrupts.length === 0
? ""
: currentInterruptId,
isWaitingForInterrupt:
visibleInterrupts.length > 0 ||
(effectiveRunPhase === "waiting_interrupt" && !isPersistedThreadActive),
isAwaitingInterrupt: effectiveRunPhase === "awaiting_interrupt",
isBusinessRunning: effectiveIsBusinessRunning,
isThreadLoading: stream.isThreadLoading,
isThreadReady,
isThreadProjectionReady:
isStreamProjectionReady || isInterruptProjectionReady,
error: isCurrentThread && stream.error ? normalizeStreamError(stream.error) : null,
threadId: projectedThreadId,
submitMessage,
respondInterrupt,
respondAllInterrupts: stream.respondAll,
stopRun,
disconnect: stream.disconnect,
prepareForSessionSwitch,
};
};
export default useSuperAgentStream;