Fix async thread title generation flow
This commit is contained in:
1 parent
d522498cc9
commit
2d3033434b
6 files changed
+561
-105
No files matched your search
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user