用户引导沙箱优化
This commit is contained in:
1 parent
21f3787b8b
commit
910cee9e67
8 files changed
+604
-224
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
loadArtifactContentFromLatestWriteFile,
|
||||
loadArtifactContentFromToolCall,
|
||||
} from "./loader";
|
||||
import {
|
||||
SANDBOX_STREAM_FLUSH_MS,
|
||||
isSandboxStreamPerfEnabled,
|
||||
} from "./sandbox-stream-perf";
|
||||
import { isArtifactApiPath } from "./streaming-write-preview";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
@@ -81,6 +85,48 @@ function applyPendingTextEdit({
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the latest write_file text in a ref (always current for later readers)
|
||||
* but only publish a display snapshot on a timer while the run is in flight.
|
||||
* Display therefore does not follow `thread` object identity / every SSE chunk.
|
||||
*/
|
||||
function useThrottledWriteFileDisplay({
|
||||
filepath,
|
||||
liveContent,
|
||||
enabled,
|
||||
streaming,
|
||||
}: {
|
||||
filepath: string;
|
||||
liveContent: string | undefined;
|
||||
enabled: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
const latestRef = useRef(liveContent);
|
||||
latestRef.current = liveContent;
|
||||
const [displayed, setDisplayed] = useState(liveContent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setDisplayed(latestRef.current);
|
||||
return;
|
||||
}
|
||||
const flush = () => {
|
||||
setDisplayed((previous) =>
|
||||
Object.is(previous, latestRef.current) ? previous : latestRef.current,
|
||||
);
|
||||
};
|
||||
if (!streaming) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
const intervalId = window.setInterval(flush, SANDBOX_STREAM_FLUSH_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [enabled, filepath, streaming]);
|
||||
|
||||
return enabled && streaming ? displayed : liveContent;
|
||||
}
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
@@ -94,12 +140,17 @@ export function useArtifactContent({
|
||||
return isWriteFileStreamingUrl(filepath);
|
||||
}, [filepath]);
|
||||
const { thread, isMock, publicShareCode } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, useWriteFileStreaming, thread]);
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const liveWriteFileContent = useWriteFileStreaming
|
||||
? loadArtifactContentFromToolCall({ url: filepath, thread })
|
||||
: undefined;
|
||||
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
||||
filepath,
|
||||
liveContent: liveWriteFileContent,
|
||||
enabled: streamPerfEnabled && useWriteFileStreaming,
|
||||
streaming: thread.isLoading,
|
||||
});
|
||||
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
|
||||
const toolCallFallbackContent = useMemo(() => {
|
||||
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SANDBOX_STREAM_PERF_QUERY,
|
||||
resolveSandboxStreamPerfEnabled,
|
||||
} from "./sandbox-stream-perf";
|
||||
|
||||
test("sandbox stream perf defaults to on", () => {
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({}), true);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: null }), true);
|
||||
});
|
||||
|
||||
test("sandbox stream perf can be rolled back from hash or search query", () => {
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/chats/new?sandboxStreamPerf=0",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
search: `?${SANDBOX_STREAM_PERF_QUERY}=off`,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/agents/abc/chats/def?foo=1&sandboxStreamPerf=false",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("sandbox stream perf query wins over localStorage", () => {
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/chats/new?sandboxStreamPerf=1",
|
||||
storageValue: "0",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("sandbox stream perf can be rolled back from localStorage", () => {
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "0" }), false);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "off" }), false);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "1" }), true);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Sandbox streaming performance guard (write_file live preview).
|
||||
*
|
||||
* Default ON. Rollback without a rebuild:
|
||||
* 1. Append `?sandboxStreamPerf=0` to the page URL and refresh
|
||||
* e.g. `/#/page/workspace/chats/new?sandboxStreamPerf=0`
|
||||
* 2. Or in the console:
|
||||
* localStorage.setItem("deerflow.sandbox-stream-perf", "0"); location.reload();
|
||||
*
|
||||
* Restore the optimized path:
|
||||
* localStorage.removeItem("deerflow.sandbox-stream-perf") and drop the query, then refresh.
|
||||
*/
|
||||
export const SANDBOX_STREAM_PERF_STORAGE_KEY = "deerflow.sandbox-stream-perf";
|
||||
export const SANDBOX_STREAM_PERF_QUERY = "sandboxStreamPerf";
|
||||
/** Aligns with roundtable write_file preview (~12fps). */
|
||||
export const SANDBOX_STREAM_FLUSH_MS = 80;
|
||||
export const SANDBOX_LEGACY_SCROLL_INTERVAL_MS = 180;
|
||||
|
||||
function parseFlag(raw: string | null | undefined): boolean | null {
|
||||
if (raw == null) return null;
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (value === "0" || value === "off" || value === "false" || value === "no") {
|
||||
return false;
|
||||
}
|
||||
if (value === "1" || value === "on" || value === "true" || value === "yes") {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryValue(search: string, key: string): string | null {
|
||||
if (!search) return null;
|
||||
const normalized = search.startsWith("?") ? search.slice(1) : search;
|
||||
return new URLSearchParams(normalized).get(key);
|
||||
}
|
||||
|
||||
export function resolveSandboxStreamPerfEnabled(input: {
|
||||
search?: string;
|
||||
hash?: string;
|
||||
storageValue?: string | null;
|
||||
}): boolean {
|
||||
const searchFlag = parseFlag(queryValue(input.search ?? "", SANDBOX_STREAM_PERF_QUERY));
|
||||
if (searchFlag != null) return searchFlag;
|
||||
|
||||
const hash = input.hash ?? "";
|
||||
const hashQueryIndex = hash.indexOf("?");
|
||||
if (hashQueryIndex >= 0) {
|
||||
const hashFlag = parseFlag(
|
||||
queryValue(hash.slice(hashQueryIndex), SANDBOX_STREAM_PERF_QUERY),
|
||||
);
|
||||
if (hashFlag != null) return hashFlag;
|
||||
}
|
||||
|
||||
const storedFlag = parseFlag(input.storageValue);
|
||||
if (storedFlag != null) return storedFlag;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isSandboxStreamPerfEnabled(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
let storageValue: string | null = null;
|
||||
try {
|
||||
storageValue = window.localStorage.getItem(SANDBOX_STREAM_PERF_STORAGE_KEY);
|
||||
} catch {
|
||||
storageValue = null;
|
||||
}
|
||||
return resolveSandboxStreamPerfEnabled({
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
storageValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSandboxFollowScroll({
|
||||
follow,
|
||||
scrollToBottom,
|
||||
contentKey,
|
||||
viewMode,
|
||||
}: {
|
||||
follow: boolean;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => boolean;
|
||||
contentKey: string;
|
||||
viewMode: string;
|
||||
}) {
|
||||
const perfEnabled = isSandboxStreamPerfEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
if (!perfEnabled || !follow) return;
|
||||
let raf = 0;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
scrollToBottom("auto");
|
||||
});
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfEnabled || !follow) return;
|
||||
const tick = () => {
|
||||
scrollToBottom("auto");
|
||||
};
|
||||
tick();
|
||||
const intervalId = window.setInterval(tick, SANDBOX_LEGACY_SCROLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [perfEnabled, follow, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfEnabled || !follow) return;
|
||||
scrollToBottom("auto");
|
||||
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
|
||||
}
|
||||
@@ -178,6 +178,61 @@ function getRunMetadataStorage(): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}``
|
||||
* but **deletes that key on stream onSuccess**. A premature SSE close
|
||||
* (proxy timeout / false-positive disconnect) looks like success, so the
|
||||
* reconnect key vanishes while the backend run is still occupying the
|
||||
* thread. We keep a second key the SDK never touches and only clear it
|
||||
* after ``runs.get`` confirms a terminal status.
|
||||
*/
|
||||
const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:";
|
||||
|
||||
function persistActiveRunId(threadId: string, runId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const normalized = normalizeStoredRunId(runId);
|
||||
if (!normalized) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(`lg:stream:${threadId}`, normalized);
|
||||
window.sessionStorage.setItem(
|
||||
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
|
||||
normalized,
|
||||
);
|
||||
} catch {
|
||||
/* private-mode / quota */
|
||||
}
|
||||
}
|
||||
|
||||
function readActiveRunId(threadId: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return (
|
||||
normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`),
|
||||
) ||
|
||||
normalizeStoredRunId(window.sessionStorage.getItem(`lg:stream:${threadId}`))
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveRunId(threadId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
|
||||
window.sessionStorage.removeItem(
|
||||
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
|
||||
);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function isInflightRunStatus(status: string | undefined): boolean {
|
||||
return status === "pending" || status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式停止某会话「服务端正在跑」的 run。
|
||||
*
|
||||
@@ -192,9 +247,10 @@ export async function stopThreadRun(
|
||||
if (!threadId || typeof window === "undefined") return;
|
||||
let runId: string | null = null;
|
||||
try {
|
||||
runId = normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
);
|
||||
runId =
|
||||
normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
) || readActiveRunId(threadId);
|
||||
} catch {
|
||||
runId = null;
|
||||
}
|
||||
@@ -204,11 +260,7 @@ export async function stopThreadRun(
|
||||
} catch {
|
||||
/* 已结束 / 不存在 / 网络错误等忽略 */
|
||||
}
|
||||
try {
|
||||
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
clearActiveRunId(threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,9 +274,10 @@ export async function stopActiveThreadBeforeClarificationReply(
|
||||
let storedRunId: string | null = null;
|
||||
if (threadId && typeof window !== "undefined") {
|
||||
try {
|
||||
storedRunId = normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
);
|
||||
storedRunId =
|
||||
normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
) || readActiveRunId(threadId);
|
||||
} catch {
|
||||
storedRunId = null;
|
||||
}
|
||||
@@ -511,6 +564,22 @@ export function useThreadStream({
|
||||
const streamReconciliationInFlightRef = useRef(false);
|
||||
const streamReconnectAttemptsRef = useRef(0);
|
||||
const streamReconnectFailureToastShownRef = useRef(false);
|
||||
const activeRunIdRef = useRef<string | null>(
|
||||
threadId && typeof window !== "undefined" ? readActiveRunId(threadId) : null,
|
||||
);
|
||||
const confirmOrRejoinRef = useRef<
|
||||
(reason: "finish" | "error" | "poll" | "conflict") => Promise<
|
||||
"terminal" | "rejoined" | "unknown"
|
||||
>
|
||||
>(async () => "unknown");
|
||||
// Keep the send button in "stop" until we confirm the backend run is
|
||||
// terminal. A premature SSE close must not look like the turn ended.
|
||||
const [streamHoldOpen, setStreamHoldOpen] = useState(
|
||||
() =>
|
||||
Boolean(
|
||||
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
|
||||
),
|
||||
);
|
||||
|
||||
const markStreamActivity = useCallback(() => {
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
@@ -564,9 +633,6 @@ export function useThreadStream({
|
||||
const runMetadataStorageRef = useRef<
|
||||
ReturnType<typeof getRunMetadataStorage> | undefined
|
||||
>(undefined);
|
||||
const joinStreamRef = useRef<(runId: string) => Promise<unknown>>(
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
@@ -586,6 +652,11 @@ export function useThreadStream({
|
||||
onCreated(meta) {
|
||||
handleStreamStart(meta.thread_id);
|
||||
setOnStreamThreadId(meta.thread_id);
|
||||
if (meta.run_id) {
|
||||
activeRunIdRef.current = meta.run_id;
|
||||
persistActiveRunId(meta.thread_id, meta.run_id);
|
||||
setStreamHoldOpen(true);
|
||||
}
|
||||
const metadataAgentId = context.agent_id ?? context.agent_name;
|
||||
const extraMetadata =
|
||||
threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
|
||||
@@ -731,21 +802,9 @@ export function useThreadStream({
|
||||
// A dropped SSE while the backend run is still alive: rejoin instead of
|
||||
// toasting "Failed to fetch" / 网络波动 and leaving the answer half-done.
|
||||
if (isTransientStreamTransportError(message)) {
|
||||
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
const runId = staleThreadId
|
||||
? runMetadataStorageRef.current?.getItem(`lg:stream:${staleThreadId}`)
|
||||
: null;
|
||||
if (
|
||||
runId &&
|
||||
streamReconnectAttemptsRef.current < STREAM_RECONNECT_MAX_ATTEMPTS
|
||||
) {
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
void joinStreamRef.current(runId).catch(() => {
|
||||
// Reconciliation will retry while the run stays non-terminal.
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStreamHoldOpen(true);
|
||||
void confirmOrRejoinRef.current("error");
|
||||
return;
|
||||
}
|
||||
setOptimisticMessages([]);
|
||||
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
|
||||
@@ -754,22 +813,22 @@ export function useThreadStream({
|
||||
// silently — there is no live run to rejoin.
|
||||
if (isRunNotFoundError(message)) {
|
||||
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
if (staleThreadId && runMetadataStorageRef.current) {
|
||||
runMetadataStorageRef.current.removeItem(
|
||||
`lg:stream:${staleThreadId}`,
|
||||
);
|
||||
if (staleThreadId) {
|
||||
clearActiveRunId(staleThreadId);
|
||||
}
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
return;
|
||||
}
|
||||
// A "thread not found" 404 means this route cannot accept another turn.
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
// A "thread not found" 404 means this route cannot accept another turn.
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
@@ -780,22 +839,43 @@ export function useThreadStream({
|
||||
: iframeMatch
|
||||
? "/page/workspace/chats_iframe/new"
|
||||
: "/page/workspace/chats/new";
|
||||
navigate(target, { replace: true });
|
||||
navigate(target, { replace: true });
|
||||
return;
|
||||
}
|
||||
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
||||
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
|
||||
const friendly = friendlySendLimitMessage(message);
|
||||
if (friendly) {
|
||||
if (isActiveRunConflictError(message)) {
|
||||
setStreamHoldOpen(true);
|
||||
void confirmOrRejoinRef.current("conflict").then((result) => {
|
||||
toast.info(
|
||||
result === "rejoined"
|
||||
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
|
||||
: friendly,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.info(friendly);
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
onFinish(state) {
|
||||
markStreamActivity();
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
// Do not trust a clean SSE close as "the run is done". streamResumable
|
||||
// chats keep the backend run alive on disconnect; the SDK still fires
|
||||
// onFinish and would otherwise restore the send button.
|
||||
setStreamHoldOpen(true);
|
||||
void confirmOrRejoinRef.current("finish").then((result) => {
|
||||
if (result === "rejoined") {
|
||||
return;
|
||||
}
|
||||
setStreamHoldOpen(false);
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -821,7 +901,158 @@ export function useThreadStream({
|
||||
// new `thread` identity.
|
||||
const threadStreamRef = useRef(thread);
|
||||
threadStreamRef.current = thread;
|
||||
joinStreamRef.current = (runId: string) => thread.joinStream(runId);
|
||||
|
||||
const confirmOrRejoin = useCallback(
|
||||
async (
|
||||
reason: "finish" | "error" | "poll" | "conflict",
|
||||
): Promise<"terminal" | "rejoined" | "unknown"> => {
|
||||
const tid = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
if (!tid) {
|
||||
setStreamHoldOpen(false);
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (streamReconciliationInFlightRef.current && reason === "poll") {
|
||||
return "unknown";
|
||||
}
|
||||
streamReconciliationInFlightRef.current = true;
|
||||
try {
|
||||
const client = getAPIClient(isMock);
|
||||
let runId = activeRunIdRef.current ?? readActiveRunId(tid);
|
||||
|
||||
const applyTerminalState = async (status: string) => {
|
||||
clearActiveRunId(tid);
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
streamReconnectAttemptsRef.current = 0;
|
||||
streamReconnectFailureToastShownRef.current = false;
|
||||
const finalState = await client.threads.getState(tid);
|
||||
setThreadValueOverrides(
|
||||
finalState.values as Partial<AgentThreadState>,
|
||||
);
|
||||
if (status === "success") {
|
||||
setOptimisticMessages([]);
|
||||
if (reason !== "finish") {
|
||||
listeners.current.onFinish?.(
|
||||
finalState.values as AgentThreadState,
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["threads", "search"],
|
||||
});
|
||||
}
|
||||
} else if (reason !== "finish") {
|
||||
toast.error(
|
||||
"本轮生成未能完成,已同步当前对话内容,可重新发起请求。",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (runId) {
|
||||
try {
|
||||
const run = await client.runs.get(tid, runId);
|
||||
if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
||||
await applyTerminalState(run.status);
|
||||
return "terminal";
|
||||
}
|
||||
} catch {
|
||||
runId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!runId) {
|
||||
try {
|
||||
const runs = await client.runs.list(tid, { limit: 20 });
|
||||
const inflight = runs.find((item) =>
|
||||
isInflightRunStatus(item.status),
|
||||
);
|
||||
runId = inflight?.run_id ?? null;
|
||||
if (runId) {
|
||||
activeRunIdRef.current = runId;
|
||||
}
|
||||
} catch {
|
||||
runId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!runId) {
|
||||
setStreamHoldOpen(false);
|
||||
activeRunIdRef.current = null;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
persistActiveRunId(tid, runId);
|
||||
setStreamHoldOpen(true);
|
||||
|
||||
const streamAboutToEnd = reason === "finish" || reason === "error";
|
||||
if (
|
||||
threadStreamRef.current.isLoading &&
|
||||
!streamAboutToEnd &&
|
||||
reason !== "conflict"
|
||||
) {
|
||||
if (
|
||||
Date.now() - lastStreamActivityAtRef.current <
|
||||
STREAM_RECONNECT_IDLE_MS
|
||||
) {
|
||||
return "rejoined";
|
||||
}
|
||||
// Local SSE reader stalled while the backend run is still alive.
|
||||
// SDK stop() cancels the server run if lg:stream is set — hide it
|
||||
// first, abort only the browser reader, then join the live run.
|
||||
if (reason === "poll") {
|
||||
if (
|
||||
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
|
||||
) {
|
||||
if (!streamReconnectFailureToastShownRef.current) {
|
||||
streamReconnectFailureToastShownRef.current = true;
|
||||
toast.error(
|
||||
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
|
||||
);
|
||||
}
|
||||
return "rejoined";
|
||||
}
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
const stream = threadStreamRef.current;
|
||||
runMetadataStorageRef.current?.removeItem(`lg:stream:${tid}`);
|
||||
void Promise.resolve(stream.stop())
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (threadIdRef.current !== tid) return;
|
||||
persistActiveRunId(tid, runId);
|
||||
void stream.joinStream(runId).catch(() => undefined);
|
||||
});
|
||||
return "rejoined";
|
||||
}
|
||||
}
|
||||
|
||||
if (reason !== "conflict") {
|
||||
if (
|
||||
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
|
||||
) {
|
||||
if (!streamReconnectFailureToastShownRef.current) {
|
||||
streamReconnectFailureToastShownRef.current = true;
|
||||
toast.error(
|
||||
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
|
||||
);
|
||||
}
|
||||
return "rejoined";
|
||||
}
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
}
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
|
||||
const stream = threadStreamRef.current;
|
||||
void stream.joinStream(runId).catch(() => {
|
||||
/* poll retries while the run stays non-terminal */
|
||||
});
|
||||
return "rejoined";
|
||||
} finally {
|
||||
streamReconciliationInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[isMock, onStreamThreadId, queryClient],
|
||||
);
|
||||
confirmOrRejoinRef.current = confirmOrRejoin;
|
||||
|
||||
useEffect(() => {
|
||||
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
@@ -830,121 +1061,24 @@ export function useThreadStream({
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let refreshTimer: number | null = null;
|
||||
|
||||
const reconcileRun = async () => {
|
||||
if (
|
||||
disposed ||
|
||||
streamReconciliationInFlightRef.current ||
|
||||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
|
||||
) {
|
||||
if (disposed) return;
|
||||
const dropped = !threadStreamRef.current.isLoading;
|
||||
const idleFor = Date.now() - lastStreamActivityAtRef.current;
|
||||
if (!dropped && idleFor < STREAM_RECONCILIATION_IDLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = runMetadataStorageRef.current;
|
||||
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
|
||||
if (!runId) return;
|
||||
|
||||
streamReconciliationInFlightRef.current = true;
|
||||
try {
|
||||
const client = getAPIClient(isMock);
|
||||
const run = await client.runs.get(activeThreadId, runId);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
|
||||
// ``useStream`` retries one failed connection on its own. If that
|
||||
// reader quietly stalls, however, its internal queue never gets a
|
||||
// terminal error and the page remains frozen despite the backend
|
||||
// continuing the run.
|
||||
//
|
||||
// SDK ``stop()`` is *not* a local-only abort: its onStop hook calls
|
||||
// ``runs.cancel`` and deletes ``lg:stream:{threadId}``, which is
|
||||
// exactly "答一半就不答了". Hide the run id first so stop() only
|
||||
// aborts the browser reader, then join the still-running server run.
|
||||
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
|
||||
if (!streamReconnectFailureToastShownRef.current) {
|
||||
streamReconnectFailureToastShownRef.current = true;
|
||||
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
// Avoid enqueueing another reconnect before this new reader has had
|
||||
// enough time to receive its first event. A genuine event resets
|
||||
// the attempt counter through ``markStreamActivity``.
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
|
||||
const stream = threadStreamRef.current;
|
||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||
void Promise.resolve(stream.stop())
|
||||
.catch(() => {
|
||||
// Stopping a stale local reader is best-effort; joining is still
|
||||
// useful if the SDK already considers the stream stopped.
|
||||
})
|
||||
.finally(() => {
|
||||
if (disposed || threadIdRef.current !== activeThreadId) return;
|
||||
try {
|
||||
storage?.setItem(`lg:stream:${activeThreadId}`, runId);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
void stream.joinStream(runId).catch(() => {
|
||||
// The status poll will either reconcile a terminal run or
|
||||
// schedule the next bounded reconnect attempt.
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The server is already terminal, so the existing SSE listener cannot
|
||||
// receive any more useful events. Load the checkpoint directly before
|
||||
// resetting the SDK stream; this preserves the normal onFinish side
|
||||
// effects (e.g. marking a position delivery complete) even when the
|
||||
// browser missed the terminal event or already reported a transport
|
||||
// error and dropped ``isLoading``.
|
||||
const finalState = await client.threads.getState(activeThreadId);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||
streamReconnectAttemptsRef.current = 0;
|
||||
streamReconnectFailureToastShownRef.current = false;
|
||||
if (run.status === "success") {
|
||||
setOptimisticMessages([]);
|
||||
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
|
||||
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} else {
|
||||
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
|
||||
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
|
||||
}
|
||||
|
||||
// Changing the controlled thread id clears only the local SDK stream
|
||||
// (it does not cancel the already-terminal backend run). Switching it
|
||||
// back immediately makes useStream fetch the persisted messages, so
|
||||
// the page exits the false loading state without a manual refresh.
|
||||
setOnStreamThreadId(null);
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
if (!disposed && threadIdRef.current === activeThreadId) {
|
||||
setOnStreamThreadId(activeThreadId);
|
||||
}
|
||||
}, 0);
|
||||
} catch {
|
||||
// A transient status lookup failure must not disrupt a real stream;
|
||||
// the next polling interval will try again if it is still quiet.
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
streamReconciliationInFlightRef.current = false;
|
||||
}
|
||||
if (dropped && idleFor < 800) {
|
||||
return;
|
||||
}
|
||||
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId);
|
||||
if (!stored && !streamHoldOpen && threadStreamRef.current.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!stored && !streamHoldOpen) {
|
||||
return;
|
||||
}
|
||||
await confirmOrRejoin("poll");
|
||||
};
|
||||
|
||||
const interval = window.setInterval(
|
||||
@@ -955,11 +1089,8 @@ export function useThreadStream({
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(interval);
|
||||
if (refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
};
|
||||
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
|
||||
}, [confirmOrRejoin, onStreamThreadId, streamHoldOpen, thread.isLoading]);
|
||||
|
||||
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||
@@ -990,6 +1121,8 @@ export function useThreadStream({
|
||||
startedRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
prevMsgCountRef.current = 0;
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
setThreadValueOverrides({});
|
||||
@@ -1355,6 +1488,18 @@ export function useThreadStream({
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const friendly = friendlySendLimitMessage(message);
|
||||
if (friendly) {
|
||||
setIsUploading(false);
|
||||
if (isActiveRunConflictError(message)) {
|
||||
setOptimisticMessages([]);
|
||||
setStreamHoldOpen(true);
|
||||
const result = await confirmOrRejoin("conflict");
|
||||
toast.info(
|
||||
result === "rejoined"
|
||||
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
|
||||
: friendly,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setOptimisticMessages((messages) => {
|
||||
const human = messages.find((item) => item.type === "human");
|
||||
const kept = human ? [human] : [];
|
||||
@@ -1368,7 +1513,6 @@ export function useThreadStream({
|
||||
} as Message,
|
||||
];
|
||||
});
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
setOptimisticMessages([]);
|
||||
@@ -1378,9 +1522,33 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions, confirmOrRejoin],
|
||||
);
|
||||
|
||||
const handleThreadStop = useCallback(async () => {
|
||||
const tid = threadIdRef.current;
|
||||
const runId =
|
||||
activeRunIdRef.current ?? (tid ? readActiveRunId(tid) : null);
|
||||
setStreamHoldOpen(false);
|
||||
activeRunIdRef.current = null;
|
||||
if (tid && runId) {
|
||||
persistActiveRunId(tid, runId);
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(threadStreamRef.current.stop());
|
||||
} catch {
|
||||
/* local abort is best-effort */
|
||||
}
|
||||
if (tid && runId) {
|
||||
try {
|
||||
await getAPIClient(isMock).runs.cancel(tid, runId);
|
||||
} catch {
|
||||
/* already stopped / missing */
|
||||
}
|
||||
clearActiveRunId(tid);
|
||||
}
|
||||
}, [isMock]);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
const mergedValues =
|
||||
Object.keys(threadValueOverrides).length > 0
|
||||
@@ -1395,14 +1563,16 @@ export function useThreadStream({
|
||||
? (mergedValues.messages as Message[])
|
||||
: thread.messages;
|
||||
|
||||
const mergedThread =
|
||||
optimisticMessages.length > 0 || mergedValues !== thread.values
|
||||
? ({
|
||||
...thread,
|
||||
values: mergedValues,
|
||||
messages: [...mergedMessages, ...optimisticMessages],
|
||||
} as typeof thread)
|
||||
: thread;
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
values: mergedValues,
|
||||
messages:
|
||||
optimisticMessages.length > 0
|
||||
? [...mergedMessages, ...optimisticMessages]
|
||||
: mergedMessages,
|
||||
isLoading: thread.isLoading || streamHoldOpen,
|
||||
stop: handleThreadStop,
|
||||
} as typeof thread;
|
||||
|
||||
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
|
||||
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
|
||||
|
||||
Reference in new issue
Block a user