Fix async thread title generation flow

This commit is contained in:
Administrator committed 2026-06-28 18:09:48 +08:00
1 parent d522498cc9
commit 2d3033434b
6 files changed
+561 -105

No files matched your search

+164 -44
View File
@@ -43,9 +43,13 @@ export type ThreadStreamOptions = {
onToolEnd?: (event: ToolEndEvent) => void;
};
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
};
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
};
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean;
};
function normalizeStoredRunId(runId: string | null): string | null {
if (!runId) {
@@ -155,7 +159,7 @@ export async function stopThreadRun(
}
}
function getStreamErrorMessage(error: unknown): string {
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
}
@@ -175,8 +179,31 @@ function getStreamErrorMessage(error: unknown): string {
return nestedError;
}
}
return "Request failed.";
}
return "Request failed.";
}
async function fetchThreadTitleRefreshState(
threadId: string,
signal?: AbortSignal,
): Promise<ThreadTitleRefreshState | null> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`,
{ signal },
);
if (!response.ok) {
return null;
}
const json = (await response.json()) as {
values?: { title?: string; title_provisional?: boolean };
};
if (!json.values) {
return null;
}
return {
title: json.values.title ?? "",
title_provisional: json.values.title_provisional,
};
}
/**
* Detect "thread not found" / forbidden errors from the LangGraph SDK.
@@ -596,9 +623,12 @@ export function useThreadStream({
});
// Optimistic messages shown before the server stream responds
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(false);
const sendInFlightRef = useRef(false);
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [threadValueOverrides, setThreadValueOverrides] = useState<
Partial<ThreadTitleRefreshState>
>({});
const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human
// message id shows up, so we can bind the timestamp to the real id (see the
// reconciliation effect below). ``known`` is the set of human ids that
@@ -626,12 +656,13 @@ export function useThreadStream({
// clearing optimistic messages in that case is exactly what made submitted
// questions visually disappear — the live run that just started would be
// aborted by the very effect intended to reset stale state.
useEffect(() => {
if (threadId != null) {
// Forward direction: starting or continuing with a real thread.
// No reset needed; the upstream useStream will load history for this id.
return;
}
useEffect(() => {
if (threadId != null) {
// Forward direction: starting or continuing with a real thread.
// No reset needed; the upstream useStream will load history for this id.
setThreadValueOverrides({});
return;
}
const prevThread = threadStreamRef.current;
if (prevThread?.isLoading) {
try {
@@ -643,9 +674,88 @@ export function useThreadStream({
startedRef.current = false;
sendInFlightRef.current = false;
prevMsgCountRef.current = 0;
setOptimisticMessages([]);
setIsUploading(false);
}, [threadId]);
setOptimisticMessages([]);
setIsUploading(false);
setThreadValueOverrides({});
}, [threadId]);
useEffect(() => {
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
const effectiveTitleProvisional =
(threadValueOverrides.title_provisional ??
thread.values?.title_provisional) === true;
if (!activeThreadId || thread.isLoading || !effectiveTitleProvisional) {
return;
}
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const controller = new AbortController();
const poll = async () => {
while (!cancelled) {
try {
const nextState = await fetchThreadTitleRefreshState(
activeThreadId,
controller.signal,
);
if (cancelled || !nextState) {
return;
}
setThreadValueOverrides({
title: nextState.title,
title_provisional: nextState.title_provisional,
});
if (nextState.title) {
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === activeThreadId
? {
...t,
values: {
...t.values,
title: nextState.title,
title_provisional: nextState.title_provisional,
},
}
: t,
),
);
}
if (nextState.title_provisional !== true) {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
return;
}
} catch {
return;
}
await new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, 2000);
});
}
};
void poll();
return () => {
cancelled = true;
controller.abort();
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [
onStreamThreadId,
queryClient,
thread.isLoading,
thread.values?.title_provisional,
threadValueOverrides.title_provisional,
]);
// Clear optimistic when server messages arrive (count increases)
useEffect(() => {
@@ -910,13 +1020,22 @@ export function useThreadStream({
);
// Merge thread with optimistic messages for display
const mergedThread =
optimisticMessages.length > 0
? ({
...thread,
messages: [...thread.messages, ...optimisticMessages],
} as typeof thread)
: thread;
const mergedValues =
Object.keys(threadValueOverrides).length > 0
? {
...thread.values,
...threadValueOverrides,
}
: thread.values;
const mergedThread =
optimisticMessages.length > 0 || mergedValues !== thread.values
? ({
...thread,
values: mergedValues,
messages: [...thread.messages, ...optimisticMessages],
} as typeof thread)
: thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
@@ -1284,28 +1403,29 @@ export function useRenameThread() {
mutationFn: async ({
threadId,
title,
}: {
threadId: string;
title: string;
}) => {
await apiClient.threads.updateState(threadId, {
values: { title },
});
},
onSuccess(_, { threadId, title }) {
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
}: {
threadId: string;
title: string;
}) => {
await apiClient.threads.updateState(threadId, {
values: { title, title_provisional: false },
});
},
onSuccess(_, { threadId, title }) {
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadId
? {
...t,
values: {
...t.values,
title,
},
}
: t,
),
);
values: {
...t.values,
title,
title_provisional: false,
},
}
: t,
),
);
},
});
}
+7 -6
View File
@@ -2,12 +2,13 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> {
title: string;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadState extends Record<string, unknown> {
title: string;
title_provisional?: boolean;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string;