代码修改

This commit is contained in:
董宏凯 committed 2026-07-23 16:00:39 +08:00
1 parent 4948a80c56
commit f49def09ea
3 files changed
+168 -36

No files matched your search

+146 -29
View File
@@ -225,6 +225,22 @@ function getStreamErrorMessage(error: unknown): string {
return "Request failed.";
}
const TERMINAL_RUN_STATUSES = new Set([
"success",
"error",
"timeout",
"interrupted",
]);
// A completed server run normally sends a terminal SSE event. In local
// development, however, a Vite hot update, a proxy disconnect, or a brief
// network interruption can make the browser miss that final event while the
// backend continues (and successfully completes) the run. Do not probe on
// every render: only treat the stream as suspicious after it has been quiet
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
async function fetchThreadTitleRefreshState(
threadId: string,
signal?: AbortSignal,
@@ -428,8 +444,14 @@ export function useThreadStream({
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
// and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
}, []);
const listeners = useRef({
onStart,
@@ -461,13 +483,14 @@ export function useThreadStream({
}
}, []);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
_handleOnStart(_threadId);
},
[_handleOnStart],
);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
markStreamActivity();
_handleOnStart(_threadId);
},
[_handleOnStart, markStreamActivity],
);
const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
@@ -491,8 +514,8 @@ export function useThreadStream({
? () => runMetadataStorageRef.current!
: false,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
handleStreamStart(meta.thread_id);
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
@@ -506,9 +529,10 @@ export function useThreadStream({
.threads.update(meta.thread_id, { metadata })
.catch(() => ({}));
}
},
onLangChainEvent(event) {
if (event.event === "on_tool_end") {
},
onLangChainEvent(event) {
markStreamActivity();
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
@@ -536,9 +560,10 @@ export function useThreadStream({
/* 上报绝不影响问答 */
}
}
},
onUpdateEvent(data) {
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
},
onUpdateEvent(data) {
markStreamActivity();
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
data || {},
);
for (const update of updates) {
@@ -559,9 +584,10 @@ export function useThreadStream({
);
}
}
},
onCustomEvent(event: unknown) {
if (
},
onCustomEvent(event: unknown) {
markStreamActivity();
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
@@ -605,9 +631,10 @@ export function useThreadStream({
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
}
},
onError(error) {
setOptimisticMessages([]);
},
onError(error) {
markStreamActivity();
setOptimisticMessages([]);
const message = getStreamErrorMessage(error);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
@@ -669,10 +696,11 @@ export function useThreadStream({
}
toast.error(message);
},
onFinish(state) {
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
onFinish(state) {
markStreamActivity();
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
});
// Optimistic messages shown before the server stream responds
@@ -695,8 +723,97 @@ export function useThreadStream({
// Keep a ref to the latest `thread` so the threadId-change effect can stop
// an in-flight stream without re-running on every render that produces a
// new `thread` identity.
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
useEffect(() => {
if (!thread.isLoading) {
// A normal terminal event reached the browser. A later run may use the
// same thread, so it must be eligible for recovery again.
streamReconciliationInFlightRef.current = false;
return;
}
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (!activeThreadId || typeof window === "undefined") {
return;
}
let disposed = false;
let refreshTimer: number | null = null;
const reconcileCompletedRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
Date.now() - lastStreamActivityAtRef.current < 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 || !TERMINAL_RUN_STATUSES.has(run.status)) {
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.
const finalState = await client.threads.getState(activeThreadId);
if (disposed || !threadStreamRef.current.isLoading) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
if (run.status === "success") {
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} else {
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;
}
}
};
const interval = window.setInterval(
() => void reconcileCompletedRun(),
STREAM_RECONCILIATION_POLL_MS,
);
return () => {
disposed = true;
window.clearInterval(interval);
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
}
};
}, [isMock, onStreamThreadId, queryClient, 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
@@ -62,6 +62,8 @@ interface PositionNodeChatProps {
path: string;
name: string;
mimeType: string | null;
/** Unique write_file invocation used to distinguish rewrites of one path. */
previewKey?: string;
auto?: boolean;
}) => void;
onThreadSnapshot?: (snapshot: {
@@ -478,7 +480,9 @@ export function PositionNodeChat({
}
// One write_file call should open at most one preview. The parent further
// guards manual close state, because it owns the page-wide singleton sandbox.
const artifactKey = `${activeThreadId}:${streamingArtifact.path}`;
// A report is often rewritten to the same output path. The tool-call key
// (not just the path) makes that a new previewable delivery.
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
@@ -486,6 +490,7 @@ export function PositionNodeChat({
path: streamingArtifact.path,
name: streamingArtifact.name,
mimeType: streamingArtifact.mimeType,
previewKey: streamingArtifact.key,
auto: true,
});
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
@@ -131,6 +131,8 @@ type PositionArtifactPreview = {
path: string;
name: string;
mimeType: string | null;
/** A write_file call id. It differentiates two generations of one path. */
previewKey?: string;
auto?: boolean;
};
type ClosureStageKind = PositionBuiltinAgentKind;
@@ -167,7 +169,12 @@ function normalizePreviewArtifactPath(path: string): string {
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
function sandboxCloseKey(artifact: Pick<PositionArtifactPreview, "threadId" | "path">): string {
function sandboxCloseKey(
artifact: Pick<PositionArtifactPreview, "threadId" | "path" | "previewKey">,
): string {
if (artifact.previewKey) {
return `${artifact.threadId}:write:${artifact.previewKey}`;
}
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
}
@@ -1323,11 +1330,13 @@ export default function PositionRoundtablePage({
}
closedSandboxKeyRef.current = null;
setSandboxArtifact((current) =>
current?.threadId === artifact.threadId && current.path === artifact.path
? current
: artifact,
);
setSandboxArtifact((current) => {
const isSamePreview =
current?.threadId === artifact.threadId &&
current.path === artifact.path &&
current.previewKey === artifact.previewKey;
return isSamePreview ? current : artifact;
});
setSandboxThreadSnapshot((current) =>
current?.threadId === artifact.threadId ? current : null,
);
@@ -1939,6 +1948,7 @@ export default function PositionRoundtablePage({
<aside className="flex w-[min(48vw,720px)] min-w-[440px] shrink-0 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none">
<ThreadContext.Provider value={sandboxThreadContextValue}>
<ArtifactFileDetail
key={sandboxCloseKey(sandboxArtifact)}
className="min-h-0 flex-1"
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}