|
|
@ -8,8 +8,8 @@ |
|
|
* 3. 订阅业务 custom channel,并通知容器刷新 workspace/sections。 |
|
|
* 3. 订阅业务 custom channel,并通知容器刷新 workspace/sections。 |
|
|
* |
|
|
* |
|
|
* 不负责: |
|
|
* 不负责: |
|
|
* 1. 创建业务 session、加载 workspace/sections 或兼容旧聊天协议。 |
|
|
* 1. 创建业务 session、加载 workspace/sections 或管理业务会话。 |
|
|
* 2. 手写 SSE、调用旧 history/checkpoint 或管理业务会话。 |
|
|
* 2. 手写 SSE、调用旧聊天协议或维护独立恢复接口。 |
|
|
* |
|
|
* |
|
|
* 维护说明: |
|
|
* 维护说明: |
|
|
* Agent v2 不可用时只暴露错误,调用方不得回退 /api/chat 或 runs/stream。 |
|
|
* Agent v2 不可用时只暴露错误,调用方不得回退 /api/chat 或 runs/stream。 |
|
|
@ -43,6 +43,21 @@ const TERMINAL_INTERRUPT_STATUSES = new Set([ |
|
|
"canceled", |
|
|
"canceled", |
|
|
]); |
|
|
]); |
|
|
|
|
|
|
|
|
|
|
|
const ACTIVE_TASK_STATUSES = new Set([ |
|
|
|
|
|
"queued", |
|
|
|
|
|
"starting", |
|
|
|
|
|
"started", |
|
|
|
|
|
"running", |
|
|
|
|
|
"pending", |
|
|
|
|
|
"processing", |
|
|
|
|
|
"generating", |
|
|
|
|
|
"writing", |
|
|
|
|
|
"active", |
|
|
|
|
|
"streaming", |
|
|
|
|
|
"resuming", |
|
|
|
|
|
"awaiting_interrupt", |
|
|
|
|
|
]); |
|
|
|
|
|
|
|
|
const getInterruptStatus = (item = {}) => { |
|
|
const getInterruptStatus = (item = {}) => { |
|
|
const payload = item?.value || item?.payload || {}; |
|
|
const payload = item?.value || item?.payload || {}; |
|
|
return String( |
|
|
return String( |
|
|
@ -63,6 +78,11 @@ const isSuperAgentEventRequest = (input) => { |
|
|
return /\/threads\/[^/]+\/stream\/events(?:\?|$)/.test(requestUrl); |
|
|
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 = {}) => { |
|
|
const isLifecycleWatcherRequest = (input, init = {}) => { |
|
|
if (!isSuperAgentEventRequest(input) || typeof init.body !== "string") { |
|
|
if (!isSuperAgentEventRequest(input) || typeof init.body !== "string") { |
|
|
return false; |
|
|
return false; |
|
|
@ -182,14 +202,32 @@ const fetchEventStreamWithRecovery = async (input, init) => { |
|
|
class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter { |
|
|
class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter { |
|
|
activeEventStream = null; |
|
|
activeEventStream = null; |
|
|
eventHandles = new Set(); |
|
|
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) { |
|
|
openEventStream(params) { |
|
|
const isLifecycleWatcher = isLifecycleWatcherFilter(params); |
|
|
const isLifecycleWatcher = isLifecycleWatcherFilter(params); |
|
|
const threadId = String(this.threadId || "").trim(); |
|
|
const threadId = String(this.threadId || "").trim(); |
|
|
|
|
|
|
|
|
if (isLifecycleWatcher) { |
|
|
if (isLifecycleWatcher) { |
|
|
// fetchWithHistoryCompatibility 会把这个 watcher 转成本地空流,
|
|
|
// useStream 的 root pump 已经覆盖 lifecycle/input,额外 watcher
|
|
|
// 不要让它参与主事件流的连接排他和释放等待。
|
|
|
// 不参与主事件流的连接排他和释放等待。
|
|
|
return super.openEventStream(params); |
|
|
return super.openEventStream(params); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -310,131 +348,6 @@ const getEventPayload = (event) => |
|
|
event?.params?.data ?? |
|
|
event?.params?.data ?? |
|
|
event; |
|
|
event; |
|
|
|
|
|
|
|
|
//[兼容历史消息]旧会话中的 tool 消息可能缺少 tool_call_id,LangChain 无法将其转换为 ToolMessage。
|
|
|
|
|
|
const isInvalidSerializedToolMessage = (message) => |
|
|
|
|
|
message?.type === "tool" && !message.tool_call_id && !message.toolCallId; |
|
|
|
|
|
|
|
|
|
|
|
//[兼容历史消息]仅清理无法被 SDK 转换的 tool 消息,保留其他状态字段和可用消息。
|
|
|
|
|
|
const sanitizeLangGraphPayload = (value, parentKey = "") => { |
|
|
|
|
|
if (Array.isArray(value)) { |
|
|
|
|
|
return value |
|
|
|
|
|
.filter((item) => parentKey !== "messages" || !isInvalidSerializedToolMessage(item)) |
|
|
|
|
|
.map((item) => sanitizeLangGraphPayload(item, parentKey)); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (!value || typeof value !== "object") return value; |
|
|
|
|
|
|
|
|
|
|
|
return Object.fromEntries( |
|
|
|
|
|
Object.entries(value).map(([key, item]) => [key, sanitizeLangGraphPayload(item, key)]) |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
//[保留同一会话的最近有效消息,避免 hydrate 短暂返回不完整快照时丢失流式内容]
|
|
|
|
|
|
const mergeStreamMessageSnapshots = (previousMessages = [], currentMessages = []) => { |
|
|
|
|
|
const previous = Array.isArray(previousMessages) ? previousMessages : []; |
|
|
|
|
|
const current = Array.isArray(currentMessages) ? currentMessages : []; |
|
|
|
|
|
if (!previous.length) return current; |
|
|
|
|
|
if (!current.length) return previous; |
|
|
|
|
|
|
|
|
|
|
|
const currentById = new Map( |
|
|
|
|
|
current |
|
|
|
|
|
.filter((message) => message?.id) |
|
|
|
|
|
.map((message) => [String(message.id), message]) |
|
|
|
|
|
); |
|
|
|
|
|
const merged = []; |
|
|
|
|
|
const usedIds = new Set(); |
|
|
|
|
|
|
|
|
|
|
|
previous.forEach((message) => { |
|
|
|
|
|
const messageId = String(message?.id || ""); |
|
|
|
|
|
if (!messageId) return; |
|
|
|
|
|
merged.push(currentById.get(messageId) || message); |
|
|
|
|
|
usedIds.add(messageId); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
current.forEach((message) => { |
|
|
|
|
|
const messageId = String(message?.id || ""); |
|
|
|
|
|
if (!messageId || usedIds.has(messageId)) return; |
|
|
|
|
|
merged.push(message); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
return merged; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
//[直接消费 root messages 事件,避免 history hydrate 覆盖正在生成的增量消息]
|
|
|
|
|
|
const applyMessageEventToState = (currentState, event) => { |
|
|
|
|
|
const data = event?.params?.data || {}; |
|
|
|
|
|
const eventType = String(data.event || ""); |
|
|
|
|
|
const nextMessages = new Map(currentState.messages); |
|
|
|
|
|
let activeMessageId = currentState.activeMessageId; |
|
|
|
|
|
const messageId = String(data.id || activeMessageId || "").trim(); |
|
|
|
|
|
if (!messageId) return currentState; |
|
|
|
|
|
|
|
|
|
|
|
const previousMessage = nextMessages.get(messageId) || { |
|
|
|
|
|
id: messageId, |
|
|
|
|
|
type: "ai", |
|
|
|
|
|
role: "ai", |
|
|
|
|
|
content: "", |
|
|
|
|
|
tool_calls: [], |
|
|
|
|
|
}; |
|
|
|
|
|
const nextMessage = { |
|
|
|
|
|
...previousMessage, |
|
|
|
|
|
tool_calls: Array.isArray(previousMessage.tool_calls) |
|
|
|
|
|
? [...previousMessage.tool_calls] |
|
|
|
|
|
: [], |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
if (eventType === "message-start") { |
|
|
|
|
|
activeMessageId = messageId; |
|
|
|
|
|
nextMessage.role = data.role || nextMessage.role || "ai"; |
|
|
|
|
|
nextMessage.type = nextMessage.role; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (eventType === "content-block-delta") { |
|
|
|
|
|
const delta = data.delta || {}; |
|
|
|
|
|
if (delta.type === "text-delta") { |
|
|
|
|
|
nextMessage.content = `${String(nextMessage.content || "")}${String(delta.text || "")}`; |
|
|
|
|
|
} |
|
|
|
|
|
if (delta.type === "tool-call-delta") { |
|
|
|
|
|
const toolCallIndex = Number(data.index || 0); |
|
|
|
|
|
const previousToolCall = nextMessage.tool_calls[toolCallIndex] || {}; |
|
|
|
|
|
const deltaArgs = delta.args; |
|
|
|
|
|
const nextArgs = |
|
|
|
|
|
previousToolCall.args && |
|
|
|
|
|
typeof previousToolCall.args === "object" && |
|
|
|
|
|
deltaArgs && |
|
|
|
|
|
typeof deltaArgs === "object" |
|
|
|
|
|
? { ...previousToolCall.args, ...deltaArgs } |
|
|
|
|
|
: deltaArgs ?? previousToolCall.args; |
|
|
|
|
|
nextMessage.tool_calls[toolCallIndex] = { |
|
|
|
|
|
...previousToolCall, |
|
|
|
|
|
args: nextArgs, |
|
|
|
|
|
status: "running", |
|
|
|
|
|
}; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (eventType === "content-block-finish") { |
|
|
|
|
|
const content = data.content || {}; |
|
|
|
|
|
if (content.type === "text") { |
|
|
|
|
|
nextMessage.content = content.text || nextMessage.content || ""; |
|
|
|
|
|
} |
|
|
|
|
|
if (content.type === "tool_call") { |
|
|
|
|
|
const toolCallIndex = Number(data.index || 0); |
|
|
|
|
|
nextMessage.tool_calls[toolCallIndex] = { |
|
|
|
|
|
...(nextMessage.tool_calls[toolCallIndex] || {}), |
|
|
|
|
|
...content, |
|
|
|
|
|
status: "success", |
|
|
|
|
|
}; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
nextMessages.set(messageId, nextMessage); |
|
|
|
|
|
return { |
|
|
|
|
|
messages: nextMessages, |
|
|
|
|
|
activeMessageId, |
|
|
|
|
|
}; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const getEventDedupeKey = (event) => { |
|
|
const getEventDedupeKey = (event) => { |
|
|
if (event?.event_id) return String(event.event_id); |
|
|
if (event?.event_id) return String(event.event_id); |
|
|
const payload = getEventPayload(event) || {}; |
|
|
const payload = getEventPayload(event) || {}; |
|
|
@ -474,20 +387,22 @@ const useSuperAgentStream = ({ |
|
|
const pendingInterruptRef = useRef(false); |
|
|
const pendingInterruptRef = useRef(false); |
|
|
const awaitingInterruptRef = useRef(false); |
|
|
const awaitingInterruptRef = useRef(false); |
|
|
const [runPhase, setRunPhase] = useState("idle"); |
|
|
const [runPhase, setRunPhase] = useState("idle"); |
|
|
|
|
|
const [isLocalRunActive, setIsLocalRunActive] = useState(false); |
|
|
const [isRespondingInterrupt, setIsRespondingInterrupt] = useState(false); |
|
|
const [isRespondingInterrupt, setIsRespondingInterrupt] = useState(false); |
|
|
const [isBusinessRunning, setIsBusinessRunning] = useState(false); |
|
|
const [isBusinessRunning, setIsBusinessRunning] = useState(false); |
|
|
const [runtimeCheckpointDeliveries, setRuntimeCheckpointDeliveries] = useState([]); |
|
|
const [runtimeCheckpointDeliveries, setRuntimeCheckpointDeliveries] = useState([]); |
|
|
const [runtimeInterrupts, setRuntimeInterrupts] = useState([]); |
|
|
const [runtimeInterrupts, setRuntimeInterrupts] = useState([]); |
|
|
const [dismissedInterruptIds, setDismissedInterruptIds] = useState(new Set()); |
|
|
const [dismissedInterruptIds, setDismissedInterruptIds] = useState(new Set()); |
|
|
|
|
|
const resolvedInterruptIdsRef = useRef(new Set()); |
|
|
const [currentInterruptId, setCurrentInterruptId] = useState(""); |
|
|
const [currentInterruptId, setCurrentInterruptId] = useState(""); |
|
|
|
|
|
const [hydratedThreadId, setHydratedThreadId] = useState(""); |
|
|
|
|
|
const [transportStateRevision, setTransportStateRevision] = useState(0); |
|
|
const eventRequestControllersRef = useRef(new Map()); |
|
|
const eventRequestControllersRef = useRef(new Map()); |
|
|
const streamSnapshotCacheRef = useRef(new Map()); |
|
|
const pendingSubmitThreadIdRef = useRef(""); |
|
|
const eventMessageStateRef = useRef({ |
|
|
const hydrationSessionRef = useRef({ |
|
|
sessionId: "", |
|
|
sessionId: "", |
|
|
activeMessageId: "", |
|
|
hasSeenLoading: false, |
|
|
messages: new Map(), |
|
|
|
|
|
}); |
|
|
}); |
|
|
const [eventStreamMessages, setEventStreamMessages] = useState([]); |
|
|
|
|
|
const localRunActiveRef = useRef(false); |
|
|
const localRunActiveRef = useRef(false); |
|
|
const localRunHasSeenLoadingRef = useRef(false); |
|
|
const localRunHasSeenLoadingRef = useRef(false); |
|
|
const activeSessionIdRef = useRef(""); |
|
|
const activeSessionIdRef = useRef(""); |
|
|
@ -504,8 +419,17 @@ const useSuperAgentStream = ({ |
|
|
}), |
|
|
}), |
|
|
[token, userId] |
|
|
[token, userId] |
|
|
); |
|
|
); |
|
|
const fetchWithHistoryCompatibility = useCallback(async (...args) => { |
|
|
const fetchUseStreamTransport = useCallback(async (...args) => { |
|
|
const [input, init = {}] = 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)) { |
|
|
if (isLifecycleWatcherRequest(input, init)) { |
|
|
// Agent 服务每个 thread 只允许一个 events subscriber。useStream
|
|
|
// Agent 服务每个 thread 只允许一个 events subscriber。useStream
|
|
|
// 的 root pump 已覆盖 lifecycle/input,额外 watcher 会稳定触发 429。
|
|
|
// 的 root pump 已覆盖 lifecycle/input,额外 watcher 会稳定触发 429。
|
|
|
@ -515,7 +439,9 @@ const useSuperAgentStream = ({ |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
let requestInit = init; |
|
|
let requestInit = init; |
|
|
const sessionKey = String(activeSessionIdRef.current || "").trim(); |
|
|
const sessionKey = String( |
|
|
|
|
|
pendingSubmitThreadIdRef.current || activeSessionIdRef.current || "" |
|
|
|
|
|
).trim(); |
|
|
if (sessionKey && isSuperAgentEventRequest(input)) { |
|
|
if (sessionKey && isSuperAgentEventRequest(input)) { |
|
|
let sessionController = eventRequestControllersRef.current.get(sessionKey); |
|
|
let sessionController = eventRequestControllersRef.current.get(sessionKey); |
|
|
if (!sessionController) { |
|
|
if (!sessionController) { |
|
|
@ -528,40 +454,31 @@ const useSuperAgentStream = ({ |
|
|
}; |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const response = await fetchEventStreamWithRecovery(input, requestInit); |
|
|
return fetchEventStreamWithRecovery(input, requestInit); |
|
|
const contentType = response.headers.get("content-type") || ""; |
|
|
|
|
|
if (!/json/i.test(contentType)) return response; |
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
|
const payload = await response.clone().json(); |
|
|
|
|
|
const sanitizedPayload = sanitizeLangGraphPayload(payload); |
|
|
|
|
|
const headers = new Headers(response.headers); |
|
|
|
|
|
headers.delete("content-length"); |
|
|
|
|
|
return new Response(JSON.stringify(sanitizedPayload), { |
|
|
|
|
|
status: response.status, |
|
|
|
|
|
statusText: response.statusText, |
|
|
|
|
|
headers, |
|
|
|
|
|
}); |
|
|
|
|
|
} catch { |
|
|
|
|
|
return response; |
|
|
|
|
|
} |
|
|
|
|
|
}, []); |
|
|
}, []); |
|
|
const eventTransport = useMemo( |
|
|
const eventTransport = useMemo( |
|
|
() => |
|
|
() => { |
|
|
new SingleSubscriberSseTransportAdapter({ |
|
|
const transport = new SingleSubscriberSseTransportAdapter({ |
|
|
apiUrl: getSuperAgentApiUrl(), |
|
|
apiUrl: getSuperAgentApiUrl(), |
|
|
defaultHeaders, |
|
|
defaultHeaders, |
|
|
fetch: fetchWithHistoryCompatibility, |
|
|
fetch: fetchUseStreamTransport, |
|
|
maxReconnectAttempts: 5, |
|
|
maxReconnectAttempts: 5, |
|
|
}), |
|
|
}); |
|
|
[defaultHeaders, fetchWithHistoryCompatibility] |
|
|
transport.onStateChange = () => { |
|
|
|
|
|
setTransportStateRevision((revision) => revision + 1); |
|
|
|
|
|
}; |
|
|
|
|
|
return transport; |
|
|
|
|
|
}, |
|
|
|
|
|
[activeSessionId, defaultHeaders, fetchUseStreamTransport] |
|
|
); |
|
|
); |
|
|
// StreamController 在 hydrate 时会先通过 transport.getState() 取新 thread;
|
|
|
// StreamController 在 hydrate 时会先通过 transport.getState() 取新 thread;
|
|
|
// 自定义 adapter 需在该 effect 运行前同步绑定目标 thread,避免读到旧会话。
|
|
|
// 自定义 adapter 需在该 effect 运行前同步绑定目标 thread,避免读到旧会话。
|
|
|
eventTransport.setThreadId(activeSessionId || ""); |
|
|
eventTransport.setThreadId( |
|
|
|
|
|
activeSessionId || pendingSubmitThreadIdRef.current || "" |
|
|
|
|
|
); |
|
|
const callerOptions = useMemo( |
|
|
const callerOptions = useMemo( |
|
|
() => ({ fetch: fetchWithHistoryCompatibility }), |
|
|
() => ({ fetch: fetchUseStreamTransport }), |
|
|
[fetchWithHistoryCompatibility] |
|
|
[fetchUseStreamTransport] |
|
|
); |
|
|
); |
|
|
const stream = useStream({ |
|
|
const stream = useStream({ |
|
|
assistantId: "freesun_agent", |
|
|
assistantId: "freesun_agent", |
|
|
@ -616,6 +533,7 @@ const useSuperAgentStream = ({ |
|
|
|
|
|
|
|
|
const handleStreamError = useCallback((error) => { |
|
|
const handleStreamError = useCallback((error) => { |
|
|
localRunActiveRef.current = false; |
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
pendingInterruptRef.current = false; |
|
|
pendingInterruptRef.current = false; |
|
|
@ -641,14 +559,16 @@ const useSuperAgentStream = ({ |
|
|
}, [activeSessionId, eventTransport]); |
|
|
}, [activeSessionId, eventTransport]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
useEffect(() => { |
|
|
eventMessageStateRef.current = { |
|
|
if (pendingSubmitThreadIdRef.current === String(activeSessionId || "")) { |
|
|
|
|
|
pendingSubmitThreadIdRef.current = ""; |
|
|
|
|
|
} |
|
|
|
|
|
hydrationSessionRef.current = { |
|
|
sessionId: String(activeSessionId || ""), |
|
|
sessionId: String(activeSessionId || ""), |
|
|
activeMessageId: "", |
|
|
hasSeenLoading: false, |
|
|
messages: new Map(), |
|
|
|
|
|
}; |
|
|
}; |
|
|
setEventStreamMessages([]); |
|
|
|
|
|
handledEventKeysRef.current.clear(); |
|
|
handledEventKeysRef.current.clear(); |
|
|
localRunActiveRef.current = false; |
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
pendingInterruptRef.current = false; |
|
|
pendingInterruptRef.current = false; |
|
|
@ -659,41 +579,58 @@ const useSuperAgentStream = ({ |
|
|
setRuntimeCheckpointDeliveries([]); |
|
|
setRuntimeCheckpointDeliveries([]); |
|
|
setRuntimeInterrupts([]); |
|
|
setRuntimeInterrupts([]); |
|
|
setDismissedInterruptIds(new Set()); |
|
|
setDismissedInterruptIds(new Set()); |
|
|
|
|
|
resolvedInterruptIdsRef.current.clear(); |
|
|
setCurrentInterruptId(""); |
|
|
setCurrentInterruptId(""); |
|
|
|
|
|
setHydratedThreadId(""); |
|
|
}, [activeSessionId]); |
|
|
}, [activeSessionId]); |
|
|
|
|
|
|
|
|
const handleMessageEvent = useCallback((event) => { |
|
|
useEffect(() => { |
|
|
if (event?.method !== "messages") return; |
|
|
const sessionId = String(activeSessionId || ""); |
|
|
const namespace = event?.params?.namespace; |
|
|
const threadId = String(stream.threadId || ""); |
|
|
if (Array.isArray(namespace) && namespace.length > 0) return; |
|
|
if (!sessionId || sessionId !== threadId) return undefined; |
|
|
|
|
|
|
|
|
const sessionId = String(activeSessionIdRef.current || "").trim(); |
|
|
let cancelled = false; |
|
|
if (!sessionId) return; |
|
|
// isThreadLoading 在 useStream hydrate 切换的中间阶段可能仍是 false,
|
|
|
if (eventMessageStateRef.current.sessionId !== sessionId) { |
|
|
// 不能用它作为快照完成标志;hydrationPromise 才代表当前 thread 的 state
|
|
|
eventMessageStateRef.current = { |
|
|
// 已经写入 root store。
|
|
|
sessionId, |
|
|
Promise.resolve(stream.hydrationPromise) |
|
|
activeMessageId: "", |
|
|
.then(() => { |
|
|
messages: new Map(), |
|
|
if (cancelled || activeSessionIdRef.current !== sessionId) return; |
|
|
}; |
|
|
setHydratedThreadId(sessionId); |
|
|
} |
|
|
}) |
|
|
|
|
|
.catch(() => undefined); |
|
|
|
|
|
|
|
|
const nextState = applyMessageEventToState(eventMessageStateRef.current, event); |
|
|
return () => { |
|
|
if (nextState === eventMessageStateRef.current) return; |
|
|
cancelled = true; |
|
|
eventMessageStateRef.current = nextState; |
|
|
}; |
|
|
setEventStreamMessages([...nextState.messages.values()]); |
|
|
}, [ |
|
|
}, []); |
|
|
activeSessionId, |
|
|
|
|
|
stream.hydrationPromise, |
|
|
|
|
|
stream.threadId, |
|
|
|
|
|
transportStateRevision, |
|
|
|
|
|
]); |
|
|
|
|
|
|
|
|
const checkpointDeliveries = useMemo(() => { |
|
|
const checkpointDeliveries = useMemo(() => { |
|
|
const deliveries = new Map(); |
|
|
const deliveries = new Map(); |
|
|
const hydratedDeliveries = Array.isArray(stream.values?.checkpoint_deliveries) |
|
|
const hydratedDeliveries = [ |
|
|
|
|
|
...(Array.isArray(stream.values?.checkpoint_deliveries) |
|
|
? stream.values.checkpoint_deliveries |
|
|
? stream.values.checkpoint_deliveries |
|
|
: []; |
|
|
: []), |
|
|
|
|
|
...(Array.isArray(eventTransport.latestState?.values?.checkpoint_deliveries) |
|
|
|
|
|
? eventTransport.latestState.values.checkpoint_deliveries |
|
|
|
|
|
: []), |
|
|
|
|
|
]; |
|
|
[...hydratedDeliveries, ...runtimeCheckpointDeliveries].forEach((item) => { |
|
|
[...hydratedDeliveries, ...runtimeCheckpointDeliveries].forEach((item) => { |
|
|
if (!item?.checkpoint_id) return; |
|
|
if (!item?.checkpoint_id) return; |
|
|
deliveries.set(item.checkpoint_id, item); |
|
|
deliveries.set(item.checkpoint_id, item); |
|
|
}); |
|
|
}); |
|
|
return [...deliveries.values()]; |
|
|
return [...deliveries.values()]; |
|
|
}, [runtimeCheckpointDeliveries, stream.values?.checkpoint_deliveries]); |
|
|
}, [ |
|
|
|
|
|
eventTransport.latestState, |
|
|
|
|
|
runtimeCheckpointDeliveries, |
|
|
|
|
|
stream.values?.checkpoint_deliveries, |
|
|
|
|
|
transportStateRevision, |
|
|
|
|
|
]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
useEffect(() => { |
|
|
if (stream.error) handleStreamError(stream.error); |
|
|
if (stream.error) handleStreamError(stream.error); |
|
|
@ -709,6 +646,7 @@ const useSuperAgentStream = ({ |
|
|
|
|
|
|
|
|
localRunActiveRef.current = false; |
|
|
localRunActiveRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
setRunPhase((currentPhase) => |
|
|
setRunPhase((currentPhase) => |
|
|
["running", "resuming"].includes(currentPhase) ? "completed" : currentPhase |
|
|
["running", "resuming"].includes(currentPhase) ? "completed" : currentPhase |
|
|
); |
|
|
); |
|
|
@ -722,23 +660,27 @@ const useSuperAgentStream = ({ |
|
|
String(stream.threadId || "") === String(activeSessionId || ""); |
|
|
String(stream.threadId || "") === String(activeSessionId || ""); |
|
|
if (!isCurrentThread) return undefined; |
|
|
if (!isCurrentThread) return undefined; |
|
|
|
|
|
|
|
|
|
|
|
const listenerSessionId = String(activeSessionId || ""); |
|
|
const currentThread = stream.getThread?.(); |
|
|
const currentThread = stream.getThread?.(); |
|
|
if (!currentThread) return undefined; |
|
|
if (!currentThread) return undefined; |
|
|
|
|
|
|
|
|
return currentThread.onEvent((event) => { |
|
|
return currentThread.onEvent((event) => { |
|
|
handleMessageEvent(event); |
|
|
// 切换会话后旧 Thread 的监听器可能在清理前再收到一个事件,
|
|
|
|
|
|
// 不能把旧会话的事件写入新会话的流式消息状态。
|
|
|
|
|
|
if (activeSessionIdRef.current !== listenerSessionId) return; |
|
|
if (event?.method === "input.requested") { |
|
|
if (event?.method === "input.requested") { |
|
|
const data = event.params?.data || {}; |
|
|
const data = event.params?.data || {}; |
|
|
const interruptId = String(data.interrupt_id || "").trim(); |
|
|
const interruptId = String(data.interrupt_id || "").trim(); |
|
|
if (interruptId) { |
|
|
if (interruptId && !resolvedInterruptIdsRef.current.has(interruptId)) { |
|
|
|
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
setCurrentInterruptId(interruptId); |
|
|
setCurrentInterruptId(interruptId); |
|
|
pendingInterruptRef.current = true; |
|
|
setRuntimeInterrupts((currentInterrupts) => { |
|
|
awaitingInterruptRef.current = false; |
|
|
const nextInterrupts = currentInterrupts.filter( |
|
|
setRunPhase("waiting_interrupt"); |
|
|
(item) => String(item?.id || "") !== interruptId |
|
|
setRuntimeInterrupts((current) => { |
|
|
); |
|
|
if (current.some((item) => item.id === interruptId)) return current; |
|
|
|
|
|
return [ |
|
|
return [ |
|
|
...current, |
|
|
...nextInterrupts, |
|
|
{ |
|
|
{ |
|
|
id: interruptId, |
|
|
id: interruptId, |
|
|
value: data.payload, |
|
|
value: data.payload, |
|
|
@ -748,6 +690,9 @@ const useSuperAgentStream = ({ |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
]; |
|
|
}); |
|
|
}); |
|
|
|
|
|
pendingInterruptRef.current = true; |
|
|
|
|
|
awaitingInterruptRef.current = false; |
|
|
|
|
|
setRunPhase("waiting_interrupt"); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
if (event?.method === "lifecycle" && Array.isArray(event?.params?.namespace) && event.params.namespace.length === 0) { |
|
|
if (event?.method === "lifecycle" && Array.isArray(event?.params?.namespace) && event.params.namespace.length === 0) { |
|
|
@ -767,6 +712,8 @@ const useSuperAgentStream = ({ |
|
|
stream.interrupts.length > 0 || |
|
|
stream.interrupts.length > 0 || |
|
|
threadInterrupts.length > 0; |
|
|
threadInterrupts.length > 0; |
|
|
if (hasRealInterrupt) { |
|
|
if (hasRealInterrupt) { |
|
|
|
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
awaitingInterruptRef.current = false; |
|
|
awaitingInterruptRef.current = false; |
|
|
setRunPhase("waiting_interrupt"); |
|
|
setRunPhase("waiting_interrupt"); |
|
|
} else { |
|
|
} else { |
|
|
@ -780,6 +727,8 @@ const useSuperAgentStream = ({ |
|
|
setRunPhase("waiting_interrupt"); |
|
|
setRunPhase("waiting_interrupt"); |
|
|
} else { |
|
|
} else { |
|
|
awaitingInterruptRef.current = false; |
|
|
awaitingInterruptRef.current = false; |
|
|
|
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
// aborted 表示本轮已结束,不能让 started 留下的 running 状态继续生效。
|
|
|
// aborted 表示本轮已结束,不能让 started 留下的 running 状态继续生效。
|
|
|
setRunPhase(lifecycleEvent === "aborted" ? "completed" : lifecycleEvent); |
|
|
setRunPhase(lifecycleEvent === "aborted" ? "completed" : lifecycleEvent); |
|
|
} |
|
|
} |
|
|
@ -793,10 +742,18 @@ const useSuperAgentStream = ({ |
|
|
if (!BUSINESS_CHANNELS.includes(event?.method)) return; |
|
|
if (!BUSINESS_CHANNELS.includes(event?.method)) return; |
|
|
handleBusinessEvent(event); |
|
|
handleBusinessEvent(event); |
|
|
}); |
|
|
}); |
|
|
}, [activeSessionId, handleBusinessEvent, handleMessageEvent, stream.isThreadLoading, stream.threadId]); |
|
|
}, [activeSessionId, handleBusinessEvent, stream.threadId]); |
|
|
|
|
|
|
|
|
const prepareForSessionSwitch = useCallback(() => { |
|
|
const prepareForSessionSwitch = useCallback(() => { |
|
|
|
|
|
// 先让旧 thread 的事件监听失效。activeSessionId 在 React 下一次渲染
|
|
|
|
|
|
// 前仍可能是旧值,单靠监听器里的 sessionId 判断不够。
|
|
|
|
|
|
activeSessionIdRef.current = ""; |
|
|
|
|
|
// pendingSubmitThreadId 只服务于“创建会话后首次发送”这一瞬间;
|
|
|
|
|
|
// 切换会话时必须清掉,否则新 transport 会继续绑定旧 thread。
|
|
|
|
|
|
pendingSubmitThreadIdRef.current = ""; |
|
|
|
|
|
setHydratedThreadId(""); |
|
|
localRunActiveRef.current = false; |
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
pendingInterruptRef.current = false; |
|
|
pendingInterruptRef.current = false; |
|
|
@ -804,18 +761,21 @@ const useSuperAgentStream = ({ |
|
|
setRunPhase("idle"); |
|
|
setRunPhase("idle"); |
|
|
setIsRespondingInterrupt(false); |
|
|
setIsRespondingInterrupt(false); |
|
|
setIsBusinessRunning(false); |
|
|
setIsBusinessRunning(false); |
|
|
|
|
|
setRuntimeInterrupts([]); |
|
|
|
|
|
resolvedInterruptIdsRef.current.clear(); |
|
|
|
|
|
|
|
|
if (stream.isLoading) { |
|
|
// 会话切换只断开当前浏览器订阅,不能发送 stop/cancel 命令。
|
|
|
void stream.stop().catch(() => undefined); |
|
|
// stopRun 只由用户点击停止按钮触发,否则后端会把原会话改为 suspended。
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// useStream 切换 threadId 时会等待旧 root pump 清理;直接关闭当前
|
|
|
// useStream 切换 threadId 时会等待旧 root pump 清理;直接关闭当前
|
|
|
// ThreadStream 可以立即中止旧 /events 请求,避免阻塞新会话接管。
|
|
|
// ThreadStream 可以立即中止旧 /events 请求,避免阻塞新会话接管。
|
|
|
const currentThread = stream.getThread?.(); |
|
|
const currentThread = stream.getThread?.(); |
|
|
if (currentThread) { |
|
|
eventTransport.close(); |
|
|
void currentThread.close().catch(() => undefined); |
|
|
|
|
|
} |
|
|
return Promise.allSettled([ |
|
|
}, [stream]); |
|
|
stream.disconnect(), |
|
|
|
|
|
currentThread ? currentThread.close() : Promise.resolve(), |
|
|
|
|
|
]); |
|
|
|
|
|
}, [eventTransport, stream]); |
|
|
|
|
|
|
|
|
const submitMessage = useCallback(async ({ |
|
|
const submitMessage = useCallback(async ({ |
|
|
content, |
|
|
content, |
|
|
@ -830,12 +790,29 @@ const useSuperAgentStream = ({ |
|
|
if (!targetThreadId) throw new Error("请先创建任务"); |
|
|
if (!targetThreadId) throw new Error("请先创建任务"); |
|
|
const isBlockedByInterrupt = isRespondingInterrupt && !allowDuringInterruptResponse; |
|
|
const isBlockedByInterrupt = isRespondingInterrupt && !allowDuringInterruptResponse; |
|
|
const isSwitchingThread = String(stream.threadId || "") !== targetThreadId; |
|
|
const isSwitchingThread = String(stream.threadId || "") !== targetThreadId; |
|
|
if ((stream.isThreadLoading && !isSwitchingThread) || isBlockedByInterrupt) { |
|
|
// isThreadLoading 只表示当前 thread 的快照还在 hydrate,不能阻止首条消息提交。
|
|
|
|
|
|
// 真正表示本轮任务仍在运行的是 isLoading;否则新建会话会先创建成功,
|
|
|
|
|
|
// 但首条消息被这里提前拒绝,页面最终只剩空会话。
|
|
|
|
|
|
if ((stream.isLoading && !isSwitchingThread) || isBlockedByInterrupt) { |
|
|
throw new Error("任务正在处理,请稍后再试"); |
|
|
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; |
|
|
localRunActiveRef.current = true; |
|
|
|
|
|
setIsLocalRunActive(true); |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
setRunPhase("running"); |
|
|
setRunPhase("running"); |
|
|
|
|
|
try { |
|
|
await stream.submit({ |
|
|
await stream.submit({ |
|
|
messages: [{ |
|
|
messages: [{ |
|
|
type: "human", |
|
|
type: "human", |
|
|
@ -849,11 +826,17 @@ const useSuperAgentStream = ({ |
|
|
multitaskStrategy, |
|
|
multitaskStrategy, |
|
|
metadata: { source: "super-agent-web" }, |
|
|
metadata: { source: "super-agent-web" }, |
|
|
}); |
|
|
}); |
|
|
}, [activeSessionId, isRespondingInterrupt, stream]); |
|
|
} catch (error) { |
|
|
|
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
|
|
|
throw error; |
|
|
|
|
|
} |
|
|
|
|
|
}, [activeSessionId, eventTransport, isRespondingInterrupt, stream]); |
|
|
|
|
|
|
|
|
const respondInterrupt = useCallback((response, options = {}) => { |
|
|
const respondInterrupt = useCallback((response, options = {}) => { |
|
|
const interruptId = String(options.interruptId || "").trim(); |
|
|
const interruptId = String(options.interruptId || "").trim(); |
|
|
localRunActiveRef.current = true; |
|
|
localRunActiveRef.current = true; |
|
|
|
|
|
setIsLocalRunActive(true); |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
interruptResumeRef.current = { active: true, running: false }; |
|
|
interruptResumeRef.current = { active: true, running: false }; |
|
|
pendingInterruptRef.current = false; |
|
|
pendingInterruptRef.current = false; |
|
|
@ -878,8 +861,11 @@ const useSuperAgentStream = ({ |
|
|
namespace: options.namespace, |
|
|
namespace: options.namespace, |
|
|
}).then((result) => { |
|
|
}).then((result) => { |
|
|
if (interruptId) { |
|
|
if (interruptId) { |
|
|
setRuntimeInterrupts((current) => |
|
|
resolvedInterruptIdsRef.current.add(interruptId); |
|
|
current.filter((item) => item.id !== interruptId) |
|
|
setRuntimeInterrupts((currentInterrupts) => |
|
|
|
|
|
currentInterrupts.filter( |
|
|
|
|
|
(item) => String(item?.id || "") !== interruptId |
|
|
|
|
|
) |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
// respond 只代表恢复命令已提交,不代表后端本轮运行完成。
|
|
|
// respond 只代表恢复命令已提交,不代表后端本轮运行完成。
|
|
|
@ -888,6 +874,7 @@ const useSuperAgentStream = ({ |
|
|
}).catch((error) => { |
|
|
}).catch((error) => { |
|
|
if (interruptId) { |
|
|
if (interruptId) { |
|
|
// 恢复失败时撤销临时隐藏,让用户可以重新提交。
|
|
|
// 恢复失败时撤销临时隐藏,让用户可以重新提交。
|
|
|
|
|
|
resolvedInterruptIdsRef.current.delete(interruptId); |
|
|
setDismissedInterruptIds((current) => { |
|
|
setDismissedInterruptIds((current) => { |
|
|
const next = new Set(current); |
|
|
const next = new Set(current); |
|
|
next.delete(interruptId); |
|
|
next.delete(interruptId); |
|
|
@ -896,6 +883,8 @@ const useSuperAgentStream = ({ |
|
|
setCurrentInterruptId((currentId) => currentId || interruptId); |
|
|
setCurrentInterruptId((currentId) => currentId || interruptId); |
|
|
} |
|
|
} |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
|
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
pendingInterruptRef.current = true; |
|
|
pendingInterruptRef.current = true; |
|
|
setRunPhase("waiting_interrupt"); |
|
|
setRunPhase("waiting_interrupt"); |
|
|
setIsRespondingInterrupt(false); |
|
|
setIsRespondingInterrupt(false); |
|
|
@ -905,6 +894,7 @@ const useSuperAgentStream = ({ |
|
|
|
|
|
|
|
|
const stopRun = useCallback(async () => { |
|
|
const stopRun = useCallback(async () => { |
|
|
localRunActiveRef.current = false; |
|
|
localRunActiveRef.current = false; |
|
|
|
|
|
setIsLocalRunActive(false); |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
localRunHasSeenLoadingRef.current = false; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
interruptResumeRef.current = { active: false, running: false }; |
|
|
pendingInterruptRef.current = false; |
|
|
pendingInterruptRef.current = false; |
|
|
@ -917,48 +907,211 @@ const useSuperAgentStream = ({ |
|
|
|
|
|
|
|
|
const currentSessionId = String(activeSessionId || ""); |
|
|
const currentSessionId = String(activeSessionId || ""); |
|
|
const currentThreadId = String(stream.threadId || ""); |
|
|
const currentThreadId = String(stream.threadId || ""); |
|
|
|
|
|
const projectedThreadId = String( |
|
|
|
|
|
pendingSubmitThreadIdRef.current || currentThreadId |
|
|
|
|
|
); |
|
|
const isThreadBound = |
|
|
const isThreadBound = |
|
|
Boolean(currentSessionId) && currentSessionId === currentThreadId; |
|
|
Boolean(currentSessionId) && currentSessionId === currentThreadId; |
|
|
const cachedSnapshot = streamSnapshotCacheRef.current.get(currentSessionId) || { |
|
|
const isProjectedThreadBound = |
|
|
messages: [], |
|
|
Boolean(currentSessionId) && currentSessionId === projectedThreadId; |
|
|
toolCalls: [], |
|
|
if (hydrationSessionRef.current.sessionId !== currentSessionId) { |
|
|
values: {}, |
|
|
hydrationSessionRef.current = { |
|
|
|
|
|
sessionId: currentSessionId, |
|
|
|
|
|
hasSeenLoading: false, |
|
|
}; |
|
|
}; |
|
|
const currentValues = isThreadBound && stream.values ? stream.values : {}; |
|
|
} |
|
|
const currentValueMessages = Array.isArray(currentValues.messages) |
|
|
if (isThreadBound && stream.isThreadLoading) { |
|
|
? currentValues.messages |
|
|
hydrationSessionRef.current.hasSeenLoading = true; |
|
|
: []; |
|
|
} |
|
|
const currentMessages = isThreadBound ? stream.messages : []; |
|
|
const isThreadReady = |
|
|
const hydratedMessages = mergeStreamMessageSnapshots( |
|
|
isThreadBound && |
|
|
mergeStreamMessageSnapshots(cachedSnapshot.messages, currentValueMessages), |
|
|
!stream.isThreadLoading && |
|
|
currentMessages |
|
|
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 nextMessages = mergeStreamMessageSnapshots( |
|
|
const hydratedState = |
|
|
hydratedMessages, |
|
|
isThreadReady && |
|
|
isThreadBound ? eventStreamMessages : [] |
|
|
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 |
|
|
|
|
|
: []; |
|
|
|
|
|
// 只有 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 nextToolCalls = isThreadBound && stream.toolCalls.length |
|
|
const hydratedTaskStatus = String( |
|
|
? stream.toolCalls |
|
|
hydratedState?.values?.status || |
|
|
: cachedSnapshot.toolCalls; |
|
|
hydratedState?.values?.session_status || |
|
|
const nextValues = isThreadBound |
|
|
hydratedState?.values?.sessionStatus || |
|
|
? { ...cachedSnapshot.values, ...currentValues } |
|
|
hydratedState?.values?.task?.session_status || |
|
|
: cachedSnapshot.values; |
|
|
hydratedState?.values?.task?.sessionStatus || |
|
|
if (isThreadBound && (nextMessages.length || nextToolCalls.length || Object.keys(nextValues).length)) { |
|
|
hydratedState?.values?.task?.status || |
|
|
streamSnapshotCacheRef.current.set(currentSessionId, { |
|
|
"" |
|
|
messages: nextMessages, |
|
|
).toLowerCase(); |
|
|
toolCalls: nextToolCalls, |
|
|
const rawTaskStatus = String( |
|
|
values: { |
|
|
rawStreamValues.status || |
|
|
...nextValues, |
|
|
rawStreamValues.session_status || |
|
|
...(nextMessages.length ? { messages: nextMessages } : {}), |
|
|
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 } |
|
|
|
|
|
: {}), |
|
|
} |
|
|
} |
|
|
const isCurrentThread = isThreadBound && !stream.isThreadLoading; |
|
|
: null; |
|
|
const streamMessages = isThreadBound ? nextMessages : []; |
|
|
const persistedTaskStatus = String( |
|
|
const streamToolCalls = isThreadBound ? nextToolCalls : []; |
|
|
streamSessionStatus || streamTask?.status || "" |
|
|
const streamValues = isThreadBound ? nextValues : {}; |
|
|
).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 hasPersistedInterrupts = hydratedInterrupts.length > 0; |
|
|
|
|
|
const persistedInterrupts = |
|
|
|
|
|
hydratedState |
|
|
|
|
|
? hydratedInterrupts |
|
|
|
|
|
: 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(() => { |
|
|
const visibleInterrupts = useMemo(() => { |
|
|
if (!isThreadBound) return []; |
|
|
if (!isStreamProjectionReady && !isInterruptProjectionReady) return []; |
|
|
|
|
|
|
|
|
const merged = new Map(); |
|
|
const merged = new Map(); |
|
|
const appendInterrupt = (item, fallback = {}) => { |
|
|
const appendInterrupt = (item, fallback = {}) => { |
|
|
@ -977,35 +1130,61 @@ const useSuperAgentStream = ({ |
|
|
}); |
|
|
}); |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
stream.interrupts.forEach((item) => appendInterrupt(item)); |
|
|
streamInterrupts.forEach((item) => appendInterrupt(item)); |
|
|
runtimeInterrupts.forEach((item) => appendInterrupt(item)); |
|
|
|
|
|
const threadInterrupts = stream.getThread?.()?.interrupts || []; |
|
|
|
|
|
threadInterrupts.forEach((item) => appendInterrupt(item)); |
|
|
|
|
|
return [...merged.values()].filter( |
|
|
return [...merged.values()].filter( |
|
|
(item) => |
|
|
(item) => |
|
|
!dismissedInterruptIds.has(String(item.id || "")) && |
|
|
!dismissedInterruptIds.has(String(item.id || "")) && |
|
|
!isTerminalInterrupt(item) |
|
|
!isTerminalInterrupt(item) |
|
|
); |
|
|
); |
|
|
}, [dismissedInterruptIds, isCurrentThread, isThreadBound, runtimeInterrupts, stream]); |
|
|
}, [ |
|
|
|
|
|
dismissedInterruptIds, |
|
|
|
|
|
isInterruptProjectionReady, |
|
|
|
|
|
isStreamProjectionReady, |
|
|
|
|
|
streamInterrupts, |
|
|
|
|
|
]); |
|
|
|
|
|
|
|
|
const isRunPhaseActive = ["running", "awaiting_interrupt", "resuming"].includes(runPhase); |
|
|
const isRunPhaseActive = ["running", "awaiting_interrupt", "resuming"].includes( |
|
|
|
|
|
effectiveRunPhase |
|
|
|
|
|
); |
|
|
|
|
|
const effectiveIsLoading = |
|
|
|
|
|
isPersistedSuspended |
|
|
|
|
|
? isRespondingInterrupt |
|
|
|
|
|
: stream.isLoading || |
|
|
|
|
|
isRespondingInterrupt || |
|
|
|
|
|
isBusinessRunning || |
|
|
|
|
|
isRunPhaseActive || |
|
|
|
|
|
isPersistedExecutionActive; |
|
|
|
|
|
|
|
|
return { |
|
|
return { |
|
|
messages: streamMessages, |
|
|
messages: streamMessages, |
|
|
toolCalls: streamToolCalls, |
|
|
toolCalls: streamToolCalls, |
|
|
|
|
|
interrupt: visibleInterrupts[0] || null, |
|
|
interrupts: visibleInterrupts, |
|
|
interrupts: visibleInterrupts, |
|
|
values: streamValues, |
|
|
values: streamValues, |
|
|
checkpointDeliveries: isCurrentThread ? checkpointDeliveries : [], |
|
|
checkpointDeliveries: isStreamProjectionReady ? checkpointDeliveries : [], |
|
|
task: streamValues?.task || null, |
|
|
task: streamTask, |
|
|
isLoading: stream.isLoading || isRespondingInterrupt || isBusinessRunning || isRunPhaseActive, |
|
|
sessionStatus: streamSessionStatus, |
|
|
runPhase, |
|
|
isLoading: effectiveIsLoading, |
|
|
currentInterruptId, |
|
|
isLocalRunActive: effectiveIsLocalRunActive, |
|
|
isWaitingForInterrupt: runPhase === "waiting_interrupt" || visibleInterrupts.length > 0, |
|
|
runPhase: effectiveRunPhase, |
|
|
isAwaitingInterrupt: runPhase === "awaiting_interrupt", |
|
|
isPersistedThreadActive, |
|
|
isBusinessRunning, |
|
|
currentInterruptId: |
|
|
|
|
|
hydratedState && |
|
|
|
|
|
hydratedInterrupts.length === 0 && |
|
|
|
|
|
runtimeInterrupts.length === 0 |
|
|
|
|
|
? "" |
|
|
|
|
|
: currentInterruptId, |
|
|
|
|
|
isWaitingForInterrupt: |
|
|
|
|
|
visibleInterrupts.length > 0 || |
|
|
|
|
|
(effectiveRunPhase === "waiting_interrupt" && !isPersistedThreadActive), |
|
|
|
|
|
isAwaitingInterrupt: effectiveRunPhase === "awaiting_interrupt", |
|
|
|
|
|
isBusinessRunning: effectiveIsBusinessRunning, |
|
|
isThreadLoading: stream.isThreadLoading, |
|
|
isThreadLoading: stream.isThreadLoading, |
|
|
|
|
|
isThreadReady, |
|
|
|
|
|
isThreadProjectionReady: |
|
|
|
|
|
isStreamProjectionReady || isInterruptProjectionReady, |
|
|
error: isCurrentThread && stream.error ? normalizeStreamError(stream.error) : null, |
|
|
error: isCurrentThread && stream.error ? normalizeStreamError(stream.error) : null, |
|
|
threadId: stream.threadId, |
|
|
threadId: projectedThreadId, |
|
|
submitMessage, |
|
|
submitMessage, |
|
|
respondInterrupt, |
|
|
respondInterrupt, |
|
|
respondAllInterrupts: stream.respondAll, |
|
|
respondAllInterrupts: stream.respondAll, |
|
|
|