From 29cbd5a6adf1bf13c9c4594fa740fdec4fa85ed7 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 13:20:13 +0800 Subject: [PATCH] feat(web): isolate stream runtime by session and turn Co-authored-by: Cursor --- web/src/hooks/useTurnRuntime.test.ts | 46 +++++++++ web/src/hooks/useTurnRuntime.ts | 145 +++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 web/src/hooks/useTurnRuntime.test.ts create mode 100644 web/src/hooks/useTurnRuntime.ts diff --git a/web/src/hooks/useTurnRuntime.test.ts b/web/src/hooks/useTurnRuntime.test.ts new file mode 100644 index 00000000..efb11b11 --- /dev/null +++ b/web/src/hooks/useTurnRuntime.test.ts @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('turn runtime: session A stream survives session B switch', () => { + type TurnRuntime = { + clientTurnId: string; + sessionId: string; + status: string; + controller: { aborted: boolean }; + }; + const store = new Map>(); + + const register = (rt: TurnRuntime) => { + if (!store.has(rt.sessionId)) store.set(rt.sessionId, new Map()); + store.get(rt.sessionId)!.set(rt.clientTurnId, rt); + }; + + const isSessionThinking = (sessionId: string) => { + const m = store.get(sessionId); + if (!m) return false; + for (const rt of m.values()) { + if (rt.status === 'pending' || rt.status === 'streaming') return true; + } + return false; + }; + + register({ + clientTurnId: 'turn-a', + sessionId: 'session-A', + status: 'streaming', + controller: { aborted: false }, + }); + + assert.equal(isSessionThinking('session-A'), true); + assert.equal(isSessionThinking('session-B'), false); + + register({ + clientTurnId: 'turn-b', + sessionId: 'session-B', + status: 'pending', + controller: { aborted: false }, + }); + + assert.equal(isSessionThinking('session-A'), true); + assert.equal(isSessionThinking('session-B'), true); +}); diff --git a/web/src/hooks/useTurnRuntime.ts b/web/src/hooks/useTurnRuntime.ts new file mode 100644 index 00000000..cf66589e --- /dev/null +++ b/web/src/hooks/useTurnRuntime.ts @@ -0,0 +1,145 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react'; + +export type TurnRuntimeStatus = + | 'pending' + | 'streaming' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface TurnRuntime { + clientTurnId: string; + sessionId: string; + userMsgId: string; + asstId: string; + status: TurnRuntimeStatus; + controller: AbortController; + startedAt: number; +} + +type Listener = () => void; + +/** 按 sessionId → clientTurnId 隔离流运行态 */ +export function useTurnRuntime(activeSessionId: string) { + const storeRef = useRef({ + turns: new Map>(), + listeners: new Set(), + }); + + const subscribe = useCallback((listener: Listener) => { + storeRef.current.listeners.add(listener); + return () => { + storeRef.current.listeners.delete(listener); + }; + }, []); + + const notify = useCallback(() => { + for (const l of storeRef.current.listeners) { + l(); + } + }, []); + + const getSessionMap = useCallback((sessionId: string) => { + const store = storeRef.current; + if (!store.turns.has(sessionId)) { + store.turns.set(sessionId, new Map()); + } + return store.turns.get(sessionId)!; + }, []); + + const registerTurn = useCallback( + (runtime: TurnRuntime) => { + const sessionMap = getSessionMap(runtime.sessionId); + // 同 session 新 turn 开始前,仅中止该 session 内仍在 pending/streaming 的旧 turn + for (const [turnId, existing] of sessionMap) { + if ( + turnId !== runtime.clientTurnId && + (existing.status === 'pending' || existing.status === 'streaming') + ) { + existing.controller.abort(); + existing.status = 'cancelled'; + } + } + sessionMap.set(runtime.clientTurnId, runtime); + notify(); + }, + [getSessionMap, notify], + ); + + const updateTurnStatus = useCallback( + (sessionId: string, clientTurnId: string, status: TurnRuntimeStatus) => { + const rt = getSessionMap(sessionId).get(clientTurnId); + if (rt) { + rt.status = status; + notify(); + } + }, + [getSessionMap, notify], + ); + + const removeTurn = useCallback( + (sessionId: string, clientTurnId: string) => { + getSessionMap(sessionId).delete(clientTurnId); + notify(); + }, + [getSessionMap, notify], + ); + + const abortTurn = useCallback( + (sessionId: string, clientTurnId: string) => { + const rt = getSessionMap(sessionId).get(clientTurnId); + if (rt) { + rt.controller.abort(); + rt.status = 'cancelled'; + notify(); + } + }, + [getSessionMap, notify], + ); + + const abortActiveSessionTurns = useCallback(() => { + const sessionMap = getSessionMap(activeSessionId); + for (const rt of sessionMap.values()) { + if (rt.status === 'pending' || rt.status === 'streaming') { + rt.controller.abort(); + rt.status = 'cancelled'; + } + } + notify(); + }, [activeSessionId, getSessionMap, notify]); + + const isSessionThinking = useCallback( + (sessionId: string) => { + const sessionMap = storeRef.current.turns.get(sessionId); + if (!sessionMap) return false; + for (const rt of sessionMap.values()) { + if (rt.status === 'pending' || rt.status === 'streaming') return true; + } + return false; + }, + [], + ); + + const isThinking = useSyncExternalStore( + subscribe, + () => isSessionThinking(activeSessionId), + () => false, + ); + + const getTurn = useCallback( + (sessionId: string, clientTurnId: string) => + storeRef.current.turns.get(sessionId)?.get(clientTurnId), + [], + ); + + return { + registerTurn, + updateTurnStatus, + removeTurn, + abortTurn, + abortActiveSessionTurns, + isThinking, + isSessionThinking, + getTurn, + }; +}