mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-15 11:56:01 +00:00
fix(frontend): reset active chat after deletion (#3519)
This commit is contained in:
@@ -25,7 +25,11 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream, useThreadTokenUsage } from "@/core/threads/hooks";
|
||||
import {
|
||||
useThreadMetadata,
|
||||
useThreadStream,
|
||||
useThreadTokenUsage,
|
||||
} from "@/core/threads/hooks";
|
||||
import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -54,6 +58,10 @@ export default function AgentChatPage() {
|
||||
isNewThread || isMock ? undefined : threadId,
|
||||
{ enabled: tokenUsageEnabled && !isMock },
|
||||
);
|
||||
const threadMetadata = useThreadMetadata(threadId, {
|
||||
enabled: !isNewThread && !isMock,
|
||||
isMock,
|
||||
});
|
||||
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
@@ -106,6 +114,34 @@ export default function AgentChatPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const hasThreadMessages = thread.messages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
threadMetadata.data === null &&
|
||||
!threadMetadata.isLoading &&
|
||||
!threadMetadata.isFetching &&
|
||||
!isHistoryLoading &&
|
||||
!hasMoreHistory &&
|
||||
!hasThreadMessages
|
||||
) {
|
||||
router.replace(`/workspace/agents/${agent_name}/chats/new`);
|
||||
}
|
||||
}, [
|
||||
agent_name,
|
||||
hasMoreHistory,
|
||||
hasThreadMessages,
|
||||
isHistoryLoading,
|
||||
isMock,
|
||||
isNewThread,
|
||||
router,
|
||||
threadMetadata.data,
|
||||
threadMetadata.isFetching,
|
||||
threadMetadata.isLoading,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
const sendPromise = sendMessage(threadId, message, { agent_name });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
@@ -24,7 +25,11 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream, useThreadTokenUsage } from "@/core/threads/hooks";
|
||||
import {
|
||||
useThreadMetadata,
|
||||
useThreadStream,
|
||||
useThreadTokenUsage,
|
||||
} from "@/core/threads/hooks";
|
||||
import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -32,6 +37,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
// `isNewThread` tracks whether the backend has the thread yet — gates the
|
||||
@@ -47,6 +53,10 @@ export default function ChatPage() {
|
||||
isNewThread || isMock ? undefined : threadId,
|
||||
{ enabled: tokenUsageEnabled && !isMock },
|
||||
);
|
||||
const threadMetadata = useThreadMetadata(threadId, {
|
||||
enabled: !isNewThread && !isMock,
|
||||
isMock,
|
||||
});
|
||||
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
||||
const mountedRef = useRef(false);
|
||||
useSpecificChatMode();
|
||||
@@ -108,6 +118,33 @@ export default function ChatPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const hasThreadMessages = thread.messages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
threadMetadata.data === null &&
|
||||
!threadMetadata.isLoading &&
|
||||
!threadMetadata.isFetching &&
|
||||
!isHistoryLoading &&
|
||||
!hasMoreHistory &&
|
||||
!hasThreadMessages
|
||||
) {
|
||||
router.replace("/workspace/chats/new");
|
||||
}
|
||||
}, [
|
||||
hasMoreHistory,
|
||||
hasThreadMessages,
|
||||
isHistoryLoading,
|
||||
isMock,
|
||||
isNewThread,
|
||||
router,
|
||||
threadMetadata.data,
|
||||
threadMetadata.isFetching,
|
||||
threadMetadata.isLoading,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
const sendPromise = sendMessage(threadId, message);
|
||||
|
||||
@@ -5,6 +5,25 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
export const THREAD_CHAT_RESET_EVENT = "deer-flow:thread-chat-reset";
|
||||
|
||||
type ThreadChatResetDetail = {
|
||||
deletedThreadId: string;
|
||||
nextPath: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function resetThreadChatAfterDelete(detail: ThreadChatResetDetail) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ThreadChatResetDetail>(THREAD_CHAT_RESET_EVENT, {
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useThreadChat() {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const pathname = usePathname();
|
||||
@@ -30,6 +49,13 @@ export function useThreadChat() {
|
||||
() => threadIdFromPath === "new",
|
||||
);
|
||||
|
||||
const resetToNewThread = useCallback(() => {
|
||||
const nextThreadId = uuid();
|
||||
newThreadIdRef.current = nextThreadId;
|
||||
setIsNewThreadState(true);
|
||||
setThreadIdState(nextThreadId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewPath) {
|
||||
const nextThreadId = newThreadIdRef.current ?? uuid();
|
||||
@@ -51,6 +77,35 @@ export function useThreadChat() {
|
||||
setThreadIdState(threadIdFromPath);
|
||||
}, [isNewPath, threadIdFromPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleReset = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ThreadChatResetDetail>).detail;
|
||||
if (!detail?.nextPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPathname = window.location.pathname;
|
||||
const isDeletingCurrentThread =
|
||||
detail.force === true ||
|
||||
detail.deletedThreadId === threadId ||
|
||||
detail.deletedThreadId === threadIdFromPath ||
|
||||
currentPathname.endsWith(`/${detail.deletedThreadId}`);
|
||||
|
||||
if (!isDeletingCurrentThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
// URL replacement is owned by the caller's Next router action; this hook
|
||||
// only resets local chat state so the router state and browser URL stay
|
||||
// in sync.
|
||||
resetToNewThread();
|
||||
};
|
||||
|
||||
window.addEventListener(THREAD_CHAT_RESET_EVENT, handleReset);
|
||||
return () =>
|
||||
window.removeEventListener(THREAD_CHAT_RESET_EVENT, handleReset);
|
||||
}, [resetToNewThread, threadId, threadIdFromPath]);
|
||||
|
||||
const setThreadId = useCallback((nextThreadId: string) => {
|
||||
newThreadIdRef.current = null;
|
||||
setThreadIdState(nextThreadId);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -112,24 +113,41 @@ export function RecentChatList() {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(threadId: string) => {
|
||||
deleteThread({ threadId });
|
||||
if (threadId === threadIdFromPath) {
|
||||
const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
|
||||
let nextThreadPath = pathOfThread("new", {
|
||||
agent_name: agentNameFromPath,
|
||||
});
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
}
|
||||
(thread: AgentThread) => {
|
||||
const currentPathname =
|
||||
typeof window === "undefined" ? pathname : window.location.pathname;
|
||||
const threadPath = pathOfThread(thread);
|
||||
const nextThreadPath = pathOfThread("new", {
|
||||
agent_name: agentNameFromPath,
|
||||
});
|
||||
const isNewThreadPath = currentPathname === nextThreadPath;
|
||||
const isCurrentThread =
|
||||
thread.thread_id === threadIdFromPath ||
|
||||
threadPath === currentPathname ||
|
||||
(isNewThreadPath && threads[0]?.thread_id === thread.thread_id);
|
||||
|
||||
deleteThread({
|
||||
threadId: thread.thread_id,
|
||||
onRemoteDeleted: isCurrentThread
|
||||
? () => {
|
||||
resetThreadChatAfterDelete({
|
||||
deletedThreadId: thread.thread_id,
|
||||
nextPath: nextThreadPath,
|
||||
force: true,
|
||||
});
|
||||
void router.replace(nextThreadPath);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[agentNameFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||
[
|
||||
agentNameFromPath,
|
||||
deleteThread,
|
||||
pathname,
|
||||
router,
|
||||
threadIdFromPath,
|
||||
threads,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRenameClick = useCallback(
|
||||
@@ -302,7 +320,7 @@ export function RecentChatList() {
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleDelete(thread.thread_id)}
|
||||
onSelect={() => handleDelete(thread)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
|
||||
@@ -399,6 +399,34 @@ function getStreamErrorMessage(error: unknown): string {
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
function getHttpStatus(error: unknown): number | undefined {
|
||||
if (typeof error !== "object" || error === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const status = Reflect.get(error, "status");
|
||||
if (typeof status === "number") {
|
||||
return status;
|
||||
}
|
||||
|
||||
const response = Reflect.get(error, "response");
|
||||
if (typeof response === "object" && response !== null) {
|
||||
const responseStatus = Reflect.get(response, "status");
|
||||
if (typeof responseStatus === "number") {
|
||||
return responseStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isThreadMissingError(error: unknown): boolean {
|
||||
const status = getHttpStatus(error);
|
||||
// Treat 403 like 404 here to avoid disclosing whether an inaccessible thread
|
||||
// exists; callers redirect stale/inaccessible URLs back to a blank chat.
|
||||
return status === 403 || status === 404;
|
||||
}
|
||||
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
displayThreadId,
|
||||
@@ -1193,12 +1221,22 @@ export function useThreadHistory(
|
||||
return dedupeMessagesByIdentity([...prev, ..._messages]);
|
||||
});
|
||||
}, []);
|
||||
const hasThreadId = Boolean(threadId);
|
||||
const hasUnloadedRuns = Boolean(
|
||||
runs.data?.some((run) => !loadedRunIdsRef.current.has(run.run_id)),
|
||||
);
|
||||
const isRunsLoading =
|
||||
enabled &&
|
||||
hasThreadId &&
|
||||
(runs.isLoading || (runs.isFetching && !runs.data));
|
||||
const isRunsUnresolved =
|
||||
enabled && hasThreadId && !runs.data && !runs.isError;
|
||||
const hasMore =
|
||||
enabled && Boolean(threadId) && (indexRef.current >= 0 || !runs.data);
|
||||
enabled && hasThreadId && (indexRef.current >= 0 || hasUnloadedRuns);
|
||||
return {
|
||||
runs: runs.data,
|
||||
messages,
|
||||
loading,
|
||||
loading: loading || isRunsLoading || isRunsUnresolved,
|
||||
appendMessages,
|
||||
hasMore,
|
||||
loadMore: loadMessages,
|
||||
@@ -1313,6 +1351,36 @@ export function useThreadRuns(
|
||||
});
|
||||
}
|
||||
|
||||
export function useThreadMetadata(
|
||||
threadId?: string | null,
|
||||
{
|
||||
enabled = true,
|
||||
isMock = false,
|
||||
}: { enabled?: boolean; isMock?: boolean } = {},
|
||||
) {
|
||||
const apiClient = getAPIClient(isMock);
|
||||
return useQuery<AgentThread | null>({
|
||||
queryKey: ["thread", "metadata", threadId, isMock],
|
||||
queryFn: async () => {
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.threads.get(threadId);
|
||||
return response as AgentThread;
|
||||
} catch (error) {
|
||||
if (isThreadMissingError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: enabled && Boolean(threadId),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useThreadTokenUsage(
|
||||
threadId?: string | null,
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
@@ -1347,8 +1415,15 @@ export function useDeleteThread() {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ threadId }: { threadId: string }) => {
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
onRemoteDeleted,
|
||||
}: {
|
||||
threadId: string;
|
||||
onRemoteDeleted?: () => void;
|
||||
}) => {
|
||||
await apiClient.threads.delete(threadId);
|
||||
onRemoteDeleted?.();
|
||||
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
|
||||
Reference in New Issue
Block a user