refactor(chat): 收敛线程会话路由状态
This commit is contained in:
parent
f584c3e53b
commit
7853a6a97d
|
|
@ -39,20 +39,20 @@ export default function AgentChatPage() {
|
|||
|
||||
const { agent } = useAgent(agent_name);
|
||||
|
||||
const { threadId, isNewThread, setIsNewThread } = useThreadChat();
|
||||
const { threadId, routeKind } = useThreadChat();
|
||||
const isNewRoute = routeKind === "new";
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
threadId: isNewRoute ? undefined : threadId,
|
||||
context: { ...settings.context, agent_name: agent_name },
|
||||
onStart: () => {
|
||||
setIsNewThread(false);
|
||||
onStart: (currentThreadId) => {
|
||||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/workspace/agents/${agent_name}/chats/${threadId}`,
|
||||
`/workspace/agents/${agent_name}/chats/${currentThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
|
|
@ -89,13 +89,13 @@ export default function AgentChatPage() {
|
|||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, threadId }}>
|
||||
<ThreadContext.Provider value={{ thread, threadId: threadId ?? "" }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
|
||||
isNewThread
|
||||
isNewRoute
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
|
|
@ -109,7 +109,7 @@ export default function AgentChatPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
<ThreadTitle threadId={threadId ?? ""} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
|
|
@ -124,7 +124,7 @@ export default function AgentChatPage() {
|
|||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator messages={thread.messages} />
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ExportTrigger threadId={threadId ?? ""} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -132,8 +132,8 @@ export default function AgentChatPage() {
|
|||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isNewThread && "pt-10")}
|
||||
threadId={threadId}
|
||||
className={cn("size-full", !isNewRoute && "pt-10")}
|
||||
threadId={threadId ?? ""}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
/>
|
||||
|
|
@ -143,8 +143,8 @@ export default function AgentChatPage() {
|
|||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isNewThread && "-translate-y-[calc(50vh-96px)]",
|
||||
isNewThread
|
||||
isNewRoute && "-translate-y-[calc(50vh-96px)]",
|
||||
isNewRoute
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
|
|
@ -163,10 +163,10 @@ export default function AgentChatPage() {
|
|||
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
showWelcomeStyle={isNewThread}
|
||||
hasSubmitted={!isNewThread}
|
||||
threadId={threadId ?? ""}
|
||||
autoFocus={isNewRoute}
|
||||
isWelcomeView={isNewRoute}
|
||||
hasSubmitted={!isNewRoute}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
|
|
@ -176,7 +176,7 @@ export default function AgentChatPage() {
|
|||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isNewThread && (
|
||||
isNewRoute && (
|
||||
<AgentWelcome agent={agent} agentName={agent_name} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Ticker } from "@tombcato/smart-ticker";
|
||||
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -23,11 +23,9 @@ import {
|
|||
} from "@/components/workspace/artifacts";
|
||||
import { useThreadChat } from "@/components/workspace/chats";
|
||||
// import { DevTodoList } from "@/components/workspace/dev-todo-list";
|
||||
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
|
|
@ -60,42 +58,25 @@ export default function ChatPage() {
|
|||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
selectedArtifact,
|
||||
deselect: deselectArtifact,
|
||||
setFullscreen: setArtifactsFullscreen,
|
||||
fullscreen,
|
||||
} = useArtifacts();
|
||||
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
|
||||
const { threadId, routeKind, viewMode, lifecycleMode, isMock } =
|
||||
useThreadChat();
|
||||
|
||||
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
|
||||
const shouldRenderHistory = !showWelcomeStyle;
|
||||
const safeThreadId = useMemo(() => {
|
||||
if (!threadId || threadId === "new") {
|
||||
return undefined;
|
||||
}
|
||||
return threadId;
|
||||
}, [threadId]);
|
||||
// `/new` + `thread_id` now reuses the pre-created thread, instead of creating
|
||||
// a new session on first submit.
|
||||
const createNewSession = useMemo(
|
||||
() => isNewThread && !safeThreadId,
|
||||
[isNewThread, safeThreadId],
|
||||
);
|
||||
const isWelcomeView = viewMode === "welcome";
|
||||
const isNewRoute = routeKind === "new";
|
||||
const shouldRenderHistory = viewMode === "chat";
|
||||
const [isThreadInitReady, setIsThreadInitReady] = useState(false);
|
||||
|
||||
const streamThreadId = useMemo(() => {
|
||||
if (!safeThreadId) {
|
||||
if (!threadId) {
|
||||
return undefined;
|
||||
}
|
||||
// In /new flow, defer history loading until thread init is finished:
|
||||
// delete -> create -> history.
|
||||
if (isNewThread && !isThreadInitReady) {
|
||||
if (lifecycleMode === "reset_on_entry" && !isThreadInitReady) {
|
||||
return undefined;
|
||||
}
|
||||
return safeThreadId;
|
||||
}, [isNewThread, isThreadInitReady, safeThreadId]);
|
||||
return threadId;
|
||||
}, [isThreadInitReady, lifecycleMode, threadId]);
|
||||
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
|
||||
const warnedMissingThreadIdRef = useRef(false);
|
||||
const initializedThreadRef = useRef<string | null>(null);
|
||||
const threadInitPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
|
|
@ -135,30 +116,30 @@ export default function ChatPage() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNewThread) {
|
||||
warnedMissingThreadIdRef.current = false;
|
||||
if (lifecycleMode === "preserve") {
|
||||
initializedThreadRef.current = null;
|
||||
threadInitPromiseRef.current = null;
|
||||
setIsThreadInitReady(true);
|
||||
return;
|
||||
}
|
||||
if (!safeThreadId) {
|
||||
if (!warnedMissingThreadIdRef.current) {
|
||||
warnedMissingThreadIdRef.current = true;
|
||||
toast.error(t.chatPage.missingThreadIdForCreate);
|
||||
}
|
||||
|
||||
if (lifecycleMode !== "reset_on_entry" || !threadId) {
|
||||
initializedThreadRef.current = null;
|
||||
threadInitPromiseRef.current = null;
|
||||
setIsThreadInitReady(false);
|
||||
return;
|
||||
}
|
||||
warnedMissingThreadIdRef.current = false;
|
||||
if (initializedThreadRef.current === safeThreadId) return;
|
||||
initializedThreadRef.current = safeThreadId;
|
||||
|
||||
if (initializedThreadRef.current === threadId) return;
|
||||
initializedThreadRef.current = threadId;
|
||||
setIsThreadInitReady(false);
|
||||
|
||||
const initPromise = apiClient.threads
|
||||
.delete(safeThreadId)
|
||||
.delete(threadId)
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
apiClient.threads.create({
|
||||
threadId: safeThreadId,
|
||||
threadId,
|
||||
ifExists: "do_nothing",
|
||||
}),
|
||||
)
|
||||
|
|
@ -179,10 +160,9 @@ export default function ChatPage() {
|
|||
});
|
||||
}, [
|
||||
apiClient,
|
||||
isNewThread,
|
||||
safeThreadId,
|
||||
lifecycleMode,
|
||||
threadId,
|
||||
t.chatPage.createSessionFailed,
|
||||
t.chatPage.missingThreadIdForCreate,
|
||||
]);
|
||||
|
||||
// 监听宿主页 selectedSkill 消息
|
||||
|
|
@ -190,21 +170,16 @@ export default function ChatPage() {
|
|||
skillError: selectedSkillError,
|
||||
clearSkillError: clearSelectedSkillError,
|
||||
isBootstrapping: isSelectedSkillBootstrapping,
|
||||
} = useSelectedSkillListener({ threadId: safeThreadId ?? null });
|
||||
} = useSelectedSkillListener({ threadId: threadId ?? null });
|
||||
// 对话行为控制器
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: streamThreadId,
|
||||
context: settings.context,
|
||||
createNewSession,
|
||||
isMock,
|
||||
// 发送消息后跳转的逻辑
|
||||
onStart: (currentThreadId) => {
|
||||
setIsNewThread(false);
|
||||
// if (!shouldStayOnNewRoute) {
|
||||
// Keep /new in history so router.back() can return to it.
|
||||
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
|
||||
// }
|
||||
// history.pushState(null, "", pathOfThread(currentThreadId));
|
||||
if (isNewRoute || viewMode === "welcome") {
|
||||
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
|
||||
}
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
|
|
@ -258,7 +233,7 @@ export default function ChatPage() {
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const pageTitle = isNewThread
|
||||
const pageTitle = isNewRoute
|
||||
? t.pages.newChat
|
||||
: thread.values?.title && thread.values.title !== "Untitled"
|
||||
? thread.values.title
|
||||
|
|
@ -269,7 +244,7 @@ export default function ChatPage() {
|
|||
document.title = `${pageTitle} - ${t.pages.appName}`;
|
||||
}
|
||||
}, [
|
||||
isNewThread,
|
||||
isNewRoute,
|
||||
t.common.loading,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
|
|
@ -312,30 +287,30 @@ export default function ChatPage() {
|
|||
if (isSelectedSkillBootstrapping) {
|
||||
return;
|
||||
}
|
||||
if (isNewThread && !safeThreadId) {
|
||||
if (lifecycleMode === "missing_thread_id") {
|
||||
toast.error(t.chatPage.missingThreadIdForSend);
|
||||
return;
|
||||
}
|
||||
if (isNewThread && safeThreadId) {
|
||||
if (lifecycleMode === "reset_on_entry") {
|
||||
await threadInitPromiseRef.current;
|
||||
}
|
||||
if (isNewThread && safeThreadId && !isThreadInitReady) {
|
||||
if (lifecycleMode === "reset_on_entry" && !isThreadInitReady) {
|
||||
return;
|
||||
}
|
||||
setHasSubmitted(true);
|
||||
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
|
||||
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
|
||||
if (threadId && viewMode === "welcome") {
|
||||
router.replace(`/workspace/chats/${threadId}?is_chatting=true`);
|
||||
}
|
||||
void sendMessage(safeThreadId, message);
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[
|
||||
isNewThread,
|
||||
isThreadInitReady,
|
||||
isSelectedSkillBootstrapping,
|
||||
lifecycleMode,
|
||||
router,
|
||||
safeThreadId,
|
||||
sendMessage,
|
||||
showWelcomeStyle,
|
||||
threadId,
|
||||
viewMode,
|
||||
t.chatPage.missingThreadIdForSend,
|
||||
],
|
||||
);
|
||||
|
|
@ -343,24 +318,8 @@ export default function ChatPage() {
|
|||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const resetNewSessionState = useCallback(() => {
|
||||
setIsNewThread(true);
|
||||
setHasSubmitted(false);
|
||||
setHistoryCutoff(null);
|
||||
setArtifacts([]);
|
||||
deselectArtifact();
|
||||
setArtifactsOpen(false);
|
||||
setArtifactsFullscreen(false);
|
||||
}, [
|
||||
deselectArtifact,
|
||||
setArtifacts,
|
||||
setArtifactsFullscreen,
|
||||
setArtifactsOpen,
|
||||
setIsNewThread,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ threadId, thread }}>
|
||||
<ThreadContext.Provider value={{ threadId: threadId ?? "", thread }}>
|
||||
<div
|
||||
className={cn(
|
||||
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
|
||||
|
|
@ -379,7 +338,7 @@ export default function ChatPage() {
|
|||
<header
|
||||
className={cn(
|
||||
"bg-background absolute top-0 right-0 left-0 z-30 mx-4 grid h-[58px] shrink-0 grid-cols-3 items-center border-b transition-all duration-300 ease-in-out",
|
||||
showWelcomeStyle && !hasSubmitted ? "hidden" : "",
|
||||
isWelcomeView && !hasSubmitted ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-start overflow-hidden text-sm font-medium">
|
||||
|
|
@ -466,7 +425,7 @@ export default function ChatPage() {
|
|||
<main
|
||||
className={cn(
|
||||
"flex min-h-0 max-w-full grow flex-col",
|
||||
showWelcomeStyle && !hasSubmitted
|
||||
isWelcomeView && !hasSubmitted
|
||||
? "bg-ws-surface-base"
|
||||
: "bg-background",
|
||||
)}
|
||||
|
|
@ -475,9 +434,9 @@ export default function ChatPage() {
|
|||
<MessageList
|
||||
className={cn(
|
||||
"size-full",
|
||||
(!showWelcomeStyle || hasSubmitted) && "pt-[58px]",
|
||||
(!isWelcomeView || hasSubmitted) && "pt-[58px]",
|
||||
)}
|
||||
threadId={threadId}
|
||||
threadId={threadId ?? ""}
|
||||
thread={thread}
|
||||
messagesOverride={
|
||||
shouldRenderHistory
|
||||
|
|
@ -487,7 +446,7 @@ export default function ChatPage() {
|
|||
: thread.messages.slice(historyCutoff)
|
||||
}
|
||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||
showScrollToBottomButton={!showWelcomeStyle}
|
||||
showScrollToBottomButton={!isWelcomeView}
|
||||
scrollButtonClassName="bottom-[112px]"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -508,7 +467,7 @@ export default function ChatPage() {
|
|||
<div
|
||||
className={cn(
|
||||
"h-full w-full transition-transform duration-300 ease-in-out",
|
||||
showWelcomeStyle && !hasSubmitted ? "translate-x-0" : "",
|
||||
isWelcomeView && !hasSubmitted ? "translate-x-0" : "",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
|
|
@ -516,7 +475,7 @@ export default function ChatPage() {
|
|||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
threadId={threadId ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center px-[20px]">
|
||||
|
|
@ -548,7 +507,7 @@ export default function ChatPage() {
|
|||
<ArtifactFileList
|
||||
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
|
||||
files={sanitizedArtifacts}
|
||||
threadId={threadId}
|
||||
threadId={threadId ?? ""}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -570,19 +529,19 @@ export default function ChatPage() {
|
|||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto relative w-full max-w-[720px]",
|
||||
showWelcomeStyle &&
|
||||
isWelcomeView &&
|
||||
!hasSubmitted &&
|
||||
"-translate-y-[calc(50vh-96px)]",
|
||||
)}
|
||||
>
|
||||
{!(showWelcomeStyle && thread.isThreadLoading) ? (
|
||||
{!(isWelcomeView && thread.isThreadLoading) ? (
|
||||
<>
|
||||
<InputBox
|
||||
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")}
|
||||
threadId={threadId}
|
||||
showWelcomeStyle={showWelcomeStyle}
|
||||
threadId={threadId ?? ""}
|
||||
isWelcomeView={isWelcomeView}
|
||||
hasSubmitted={hasSubmitted}
|
||||
autoFocus={showWelcomeStyle}
|
||||
autoFocus={isWelcomeView}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
|
|
@ -593,7 +552,7 @@ export default function ChatPage() {
|
|||
context={settings.context}
|
||||
extraHeader={
|
||||
<div className="flex flex-col gap-4">
|
||||
{showWelcomeStyle && !hasSubmitted && (
|
||||
{isWelcomeView && !hasSubmitted && (
|
||||
<Welcome mode={settings.context.mode} />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -602,7 +561,7 @@ export default function ChatPage() {
|
|||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isSelectedSkillBootstrapping ||
|
||||
isUploading ||
|
||||
(isNewThread && !safeThreadId)
|
||||
lifecycleMode === "missing_thread_id"
|
||||
}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
|
|
@ -657,14 +616,13 @@ export default function ChatPage() {
|
|||
type: POST_MESSAGE_TYPES.IS_CHATTING,
|
||||
isChatting: false,
|
||||
});
|
||||
// 始终复用 query 中的 thread_id。
|
||||
const nextQuery = new URLSearchParams();
|
||||
if (threadId && threadId !== "new") {
|
||||
nextQuery.set("thread_id", threadId);
|
||||
}
|
||||
// /workspace/chats/${threadId}?is_chatting=false
|
||||
// NOTE: `/workspace/chats/new?thread_id=...` is the
|
||||
// historical "reset welcome" route. Keep it until the chat
|
||||
// URL contract is redesigned.
|
||||
router.replace(
|
||||
`/workspace/chats/new?thread_id=${threadId}`,
|
||||
threadId
|
||||
? `/workspace/chats/new?thread_id=${threadId}`
|
||||
: "/workspace/chats/new",
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import type { GroupImperativeHandle } from "react-resizable-panels";
|
|||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const { normalizeThreadId, resolveThreadChatRouteState } = await import(
|
||||
new URL("./thread-chat-route.ts", import.meta.url).href
|
||||
);
|
||||
|
||||
void test("resolveThreadChatRouteState treats /chats/new as invalid new", () => {
|
||||
const result = resolveThreadChatRouteState({
|
||||
pathname: "/workspace/chats/new",
|
||||
paramsThreadId: "new",
|
||||
queryThreadId: null,
|
||||
isChatting: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
threadId: undefined,
|
||||
routeKind: "new",
|
||||
viewMode: "welcome",
|
||||
lifecycleMode: "missing_thread_id",
|
||||
});
|
||||
});
|
||||
|
||||
void test("resolveThreadChatRouteState treats /chats/new?thread_id=T1 as reset welcome", () => {
|
||||
const result = resolveThreadChatRouteState({
|
||||
pathname: "/workspace/chats/new",
|
||||
paramsThreadId: "new",
|
||||
queryThreadId: "T1",
|
||||
isChatting: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
threadId: "T1",
|
||||
routeKind: "new",
|
||||
viewMode: "welcome",
|
||||
lifecycleMode: "reset_on_entry",
|
||||
});
|
||||
});
|
||||
|
||||
void test("resolveThreadChatRouteState treats /chats/:id without is_chatting as preserve welcome", () => {
|
||||
const result = resolveThreadChatRouteState({
|
||||
pathname: "/workspace/chats/T1",
|
||||
paramsThreadId: "T1",
|
||||
queryThreadId: null,
|
||||
isChatting: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
threadId: "T1",
|
||||
routeKind: "thread",
|
||||
viewMode: "welcome",
|
||||
lifecycleMode: "preserve",
|
||||
});
|
||||
});
|
||||
|
||||
void test("resolveThreadChatRouteState treats /chats/:id?is_chatting=false as preserve welcome", () => {
|
||||
const result = resolveThreadChatRouteState({
|
||||
pathname: "/workspace/chats/T1",
|
||||
paramsThreadId: "T1",
|
||||
queryThreadId: null,
|
||||
isChatting: "false",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
threadId: "T1",
|
||||
routeKind: "thread",
|
||||
viewMode: "welcome",
|
||||
lifecycleMode: "preserve",
|
||||
});
|
||||
});
|
||||
|
||||
void test("resolveThreadChatRouteState treats /chats/:id?is_chatting=true as chat view", () => {
|
||||
const result = resolveThreadChatRouteState({
|
||||
pathname: "/workspace/chats/T1",
|
||||
paramsThreadId: "T1",
|
||||
queryThreadId: null,
|
||||
isChatting: "true",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
threadId: "T1",
|
||||
routeKind: "thread",
|
||||
viewMode: "chat",
|
||||
lifecycleMode: "preserve",
|
||||
});
|
||||
});
|
||||
|
||||
void test("normalizeThreadId drops reserved and empty values", () => {
|
||||
assert.equal(normalizeThreadId(""), undefined);
|
||||
assert.equal(normalizeThreadId("new"), undefined);
|
||||
assert.equal(normalizeThreadId(" undefined "), undefined);
|
||||
assert.equal(normalizeThreadId(" null "), undefined);
|
||||
assert.equal(normalizeThreadId(" T1 "), "T1");
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
export type RouteKind = "new" | "thread";
|
||||
export type ViewMode = "welcome" | "chat";
|
||||
export type LifecycleMode =
|
||||
| "missing_thread_id"
|
||||
| "reset_on_entry"
|
||||
| "preserve";
|
||||
|
||||
export type ThreadChatRouteState = {
|
||||
threadId?: string;
|
||||
routeKind: RouteKind;
|
||||
viewMode: ViewMode;
|
||||
lifecycleMode: LifecycleMode;
|
||||
};
|
||||
|
||||
export type ResolveThreadChatRouteStateInput = {
|
||||
pathname: string;
|
||||
paramsThreadId?: string | null;
|
||||
queryThreadId?: string | null;
|
||||
isChatting?: string | null;
|
||||
};
|
||||
|
||||
export function resolveThreadChatRouteState({
|
||||
pathname,
|
||||
paramsThreadId,
|
||||
queryThreadId,
|
||||
isChatting,
|
||||
}: ResolveThreadChatRouteStateInput): ThreadChatRouteState {
|
||||
const pathThreadId =
|
||||
paramsThreadId?.trim() ?? extractThreadIdFromPathname(pathname);
|
||||
const routeKind: RouteKind = pathThreadId === "new" ? "new" : "thread";
|
||||
const threadId =
|
||||
routeKind === "new"
|
||||
? normalizeThreadId(queryThreadId)
|
||||
: normalizeThreadId(pathThreadId);
|
||||
const viewMode: ViewMode =
|
||||
routeKind === "new" ? "welcome" : isChatting === "true" ? "chat" : "welcome";
|
||||
const lifecycleMode: LifecycleMode =
|
||||
routeKind === "new"
|
||||
? threadId
|
||||
? "reset_on_entry"
|
||||
: "missing_thread_id"
|
||||
: "preserve";
|
||||
|
||||
return {
|
||||
threadId,
|
||||
routeKind,
|
||||
viewMode,
|
||||
lifecycleMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeThreadId(value?: string | null): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized === "new" ||
|
||||
normalized === "undefined" ||
|
||||
normalized === "null"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function extractThreadIdFromPathname(
|
||||
pathname: string,
|
||||
): string | undefined {
|
||||
const parts = pathname.split("?")[0]?.split("/") ?? [];
|
||||
const idx = parts.lastIndexOf("chats");
|
||||
if (idx >= 0 && parts.length > idx + 1) {
|
||||
return parts[idx + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1,88 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { resolveThreadChatRouteState } from "./thread-chat-route";
|
||||
|
||||
export type {
|
||||
LifecycleMode,
|
||||
RouteKind,
|
||||
ThreadChatRouteState,
|
||||
ViewMode,
|
||||
} from "./thread-chat-route";
|
||||
|
||||
export function useThreadChat() {
|
||||
const pathname = usePathname();
|
||||
const params = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const threadIdFromSearchParams = searchParams.get("thread_id")?.trim();
|
||||
// showWelcomeStyle的子判断
|
||||
const isChattingFromQuery = (() => {
|
||||
const isChatting = searchParams.get("is_chatting");
|
||||
return isChatting === "true";
|
||||
})();
|
||||
// 兜底:当 params 还未就绪时,从 pathname 解析 thread_id。
|
||||
const threadIdFromPathname = (() => {
|
||||
const parts = pathname.split("?")[0]?.split("/") ?? [];
|
||||
const idx = parts.lastIndexOf("chats");
|
||||
if (idx >= 0 && parts.length > idx + 1) {
|
||||
return parts[idx + 1];
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const rawPathThreadId = params?.thread_id ?? threadIdFromPathname;
|
||||
|
||||
const isNewRoute = rawPathThreadId === "new";
|
||||
const threadIdFromPathOrParams = isNewRoute
|
||||
? normalizeThreadId(threadIdFromSearchParams)
|
||||
: normalizeThreadId(rawPathThreadId);
|
||||
// console.log("[useThreadChat] pathname", pathname);
|
||||
// console.log("[useThreadChat] params.thread_id", params?.thread_id);
|
||||
// console.log("[useThreadChat] threadIdFromPathname", threadIdFromPathname);
|
||||
// console.log("[useThreadChat] threadIdFromPath", threadIdFromPath);
|
||||
// New session is only controlled by `/workspace/chats/new`.
|
||||
const [isNewThread, setIsNewThread] = useState(() => isNewRoute);
|
||||
|
||||
const [showWelcomeStyle, setShowWelcomeStyle] = useState(() => {
|
||||
return isNewRoute || !isChattingFromQuery;
|
||||
});
|
||||
// console.log("[useThreadChat] effectiveThreadIdFromPath", effectiveThreadIdFromPath);
|
||||
|
||||
const [threadId, setThreadId] = useState<string>(() => {
|
||||
return threadIdFromPathOrParams ?? "";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 记住最近一次有效的 thread_id,供下次加载兜底使用。
|
||||
if (threadId && threadId !== "new" && typeof window !== "undefined") {
|
||||
window.sessionStorage.setItem("workspace.thread_id", threadId);
|
||||
}
|
||||
setIsNewThread(isNewRoute);
|
||||
// Prefer path thread id, fall back to query thread_id when path is /new.
|
||||
setThreadId(threadIdFromPathOrParams ?? "");
|
||||
setShowWelcomeStyle(isNewRoute || !isChattingFromQuery);
|
||||
}, [
|
||||
isNewRoute,
|
||||
// NOTE: The current chat URLs are a historical compatibility layer.
|
||||
// `/workspace/chats/new?thread_id=...` and `?is_chatting=` should eventually
|
||||
// be redesigned, but this hook remains the single source of truth for the
|
||||
// current contract until that URL cleanup happens.
|
||||
const routeState = resolveThreadChatRouteState({
|
||||
pathname,
|
||||
searchParams,
|
||||
isChattingFromQuery,
|
||||
threadId,
|
||||
threadIdFromPathOrParams,
|
||||
]);
|
||||
const isMock = searchParams.get("mock") === "true";
|
||||
paramsThreadId: params?.thread_id,
|
||||
queryThreadId: searchParams.get("thread_id"),
|
||||
isChatting: searchParams.get("is_chatting"),
|
||||
});
|
||||
|
||||
return {
|
||||
threadId,
|
||||
isNewThread,
|
||||
setIsNewThread,
|
||||
isMock,
|
||||
showWelcomeStyle,
|
||||
...routeState,
|
||||
isMock: searchParams.get("mock") === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeThreadId(value?: string | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return isValidThreadId(value) ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isValidThreadId(value?: string | null): value is string {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (
|
||||
normalized.length > 0 &&
|
||||
normalized !== "new" &&
|
||||
normalized !== "undefined" &&
|
||||
normalized !== "null"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ export function InputBox({
|
|||
status,
|
||||
context,
|
||||
extraHeader,
|
||||
showWelcomeStyle,
|
||||
isWelcomeView,
|
||||
hasSubmitted,
|
||||
initialValue,
|
||||
onContextChange,
|
||||
|
|
@ -237,7 +237,7 @@ export function InputBox({
|
|||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
showWelcomeStyle: boolean;
|
||||
isWelcomeView: boolean;
|
||||
hasSubmitted?: boolean;
|
||||
initialValue?: string;
|
||||
onContextChange?: (
|
||||
|
|
@ -294,14 +294,14 @@ export function InputBox({
|
|||
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
|
||||
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps);
|
||||
|
||||
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
||||
// Welcome view 时禁用收缩,始终保持展开(除非已提交消息)
|
||||
const effectiveIsFocused =
|
||||
((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused;
|
||||
((isWelcomeView ?? false) && !hasSubmitted) || isFocused;
|
||||
const shouldShowSuggestionList =
|
||||
showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill";
|
||||
isWelcomeView && !hasSubmitted && searchParams.get("mode") !== "skill";
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWelcomeStyle || hasSubmitted) {
|
||||
if (!isWelcomeView || hasSubmitted) {
|
||||
setIsInputToolsTourReady(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -317,7 +317,7 @@ export function InputBox({
|
|||
});
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [
|
||||
showWelcomeStyle,
|
||||
isWelcomeView,
|
||||
hasSubmitted,
|
||||
shouldShowSuggestionList,
|
||||
iframeSkill.isBootstrapping,
|
||||
|
|
@ -325,7 +325,7 @@ export function InputBox({
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWelcomeStyle || hasSubmitted || !isInputToolsTourReady) {
|
||||
if (!isWelcomeView || hasSubmitted || !isInputToolsTourReady) {
|
||||
setIsInputToolsTourOpen(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ export function InputBox({
|
|||
if (!hasSeenTourForCurrentThread) {
|
||||
setIsInputToolsTourOpen(true);
|
||||
}
|
||||
}, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady, threadId]);
|
||||
}, [isWelcomeView, hasSubmitted, isInputToolsTourReady, threadId]);
|
||||
|
||||
const finishInputToolsTour = useCallback(() => {
|
||||
const seenState = parseInputToolsTourSeenState(
|
||||
|
|
@ -515,7 +515,7 @@ export function InputBox({
|
|||
return;
|
||||
}
|
||||
setIsFocused(false);
|
||||
if (showWelcomeStyle) {
|
||||
if (isWelcomeView) {
|
||||
sendToParent({
|
||||
type: POST_MESSAGE_TYPES.IS_CHATTING,
|
||||
isChatting: true,
|
||||
|
|
@ -529,7 +529,7 @@ export function InputBox({
|
|||
setReferences([]);
|
||||
},
|
||||
[
|
||||
showWelcomeStyle,
|
||||
isWelcomeView,
|
||||
onSubmit,
|
||||
onStop,
|
||||
references,
|
||||
|
|
@ -760,7 +760,7 @@ export function InputBox({
|
|||
|
||||
return () => controller.abort();
|
||||
*/
|
||||
}, [disabled, showWelcomeStyle, threadId]);
|
||||
}, [disabled, isWelcomeView, threadId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -816,7 +816,7 @@ export function InputBox({
|
|||
inputGroupClassName={cn(
|
||||
"border-0 rounded-[20px] backdrop-blur-sm",
|
||||
"transition-[height] duration-300 ease-out shadow-none ",
|
||||
!showWelcomeStyle && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
|
||||
!isWelcomeView && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
|
||||
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
||||
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
||||
)}
|
||||
|
|
@ -839,7 +839,7 @@ export function InputBox({
|
|||
)}
|
||||
disabled={isInputDisabled}
|
||||
placeholder={
|
||||
showWelcomeStyle
|
||||
isWelcomeView
|
||||
? t.inputBox.welcomePlaceholder
|
||||
: t.inputBox.chatPlaceholder
|
||||
}
|
||||
|
|
@ -962,7 +962,7 @@ export function InputBox({
|
|||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
{showWelcomeStyle && (
|
||||
{isWelcomeView && (
|
||||
<div ref={historyButtonTourRef} className="shrink-0 h-full">
|
||||
<HistoryButton
|
||||
router={router}
|
||||
|
|
@ -970,7 +970,7 @@ export function InputBox({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{!showWelcomeStyle && (
|
||||
{!isWelcomeView && (
|
||||
<div className="shrink-0 h-full">
|
||||
<ExitChattingButton
|
||||
router={router}
|
||||
|
|
@ -1047,7 +1047,7 @@ export function InputBox({
|
|||
)}
|
||||
|
||||
{!disabled &&
|
||||
!showWelcomeStyle &&
|
||||
!isWelcomeView &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0) && (
|
||||
<div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ export function ThreadTitle({
|
|||
threadTitle?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
const { routeKind } = useThreadChat();
|
||||
const isNewRoute = routeKind === "new";
|
||||
useEffect(() => {
|
||||
if (!thread) {
|
||||
return;
|
||||
|
|
@ -27,7 +28,7 @@ export function ThreadTitle({
|
|||
|
||||
if (thread.values?.title) {
|
||||
_title = thread.values.title;
|
||||
} else if (isNewThread) {
|
||||
} else if (isNewRoute) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
if (thread.isThreadLoading) {
|
||||
|
|
@ -36,7 +37,7 @@ export function ThreadTitle({
|
|||
document.title = `${_title} - ${t.pages.appName}`;
|
||||
}
|
||||
}, [
|
||||
isNewThread,
|
||||
isNewRoute,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
|
|
|
|||
|
|
@ -2,33 +2,10 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||
|
||||
import type { AgentThread } from "./types";
|
||||
|
||||
export interface ThreadQueryIntentInput {
|
||||
pathThreadId?: string | null;
|
||||
queryThreadId?: string | null;
|
||||
isNewRoute?: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadQueryIntent {
|
||||
threadId: string | undefined;
|
||||
isNewThread: boolean;
|
||||
showWelcomeStyle: boolean;
|
||||
}
|
||||
|
||||
export function pathOfThread(threadId: string) {
|
||||
return `/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
function normalizeThreadId(value?: string | null): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "new") {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
|
|
|
|||
Loading…
Reference in New Issue