684 lines
24 KiB
TypeScript
684 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { Ticker } from "@tombcato/smart-ticker";
|
||
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
DevDialog,
|
||
DevDialogContent,
|
||
DevDialogFooter,
|
||
DevDialogHeader,
|
||
DevDialogTitle,
|
||
} from "@/components/ui/dev-dialog";
|
||
import { useSidebar } from "@/components/ui/sidebar";
|
||
import {
|
||
ArtifactFileDetail,
|
||
ArtifactFileList,
|
||
useArtifacts,
|
||
} 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";
|
||
import { getAPIClient } from "@/core/api";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||
import { useNotification } from "@/core/notification/hooks";
|
||
import { useLocalSettings } from "@/core/settings";
|
||
import { useThreadStream } from "@/core/threads/hooks";
|
||
import { textOfMessage } from "@/core/threads/utils";
|
||
import { env } from "@/env";
|
||
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import "@tombcato/smart-ticker/style.css";
|
||
import motivationSlogans from "./motivation-slogans.json";
|
||
|
||
export default function ChatPage() {
|
||
const { t } = useI18n();
|
||
useSpecificChatMode();
|
||
const [sloganIndex, setSloganIndex] = useState(0);
|
||
const [settings, setSettings] = useLocalSettings();
|
||
const { setOpen: setSidebarOpen } = useSidebar();
|
||
const router = useRouter();
|
||
const {
|
||
artifacts,
|
||
open: artifactsOpen,
|
||
setOpen: setArtifactsOpen,
|
||
setArtifacts,
|
||
select: selectArtifact,
|
||
selectedArtifact,
|
||
deselect: deselectArtifact,
|
||
setFullscreen: setArtifactsFullscreen,
|
||
fullscreen,
|
||
} = useArtifacts();
|
||
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
|
||
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 streamThreadId = useMemo(() => {
|
||
if (isNewThread && createNewSession) {
|
||
return undefined;
|
||
}
|
||
return safeThreadId;
|
||
}, [createNewSession, isNewThread, safeThreadId]);
|
||
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
|
||
const warnedMissingThreadIdRef = useRef(false);
|
||
const initializedThreadRef = useRef<string | null>(null);
|
||
|
||
const { showNotification } = useNotification();
|
||
const currentSlogan = motivationSlogans[
|
||
sloganIndex % motivationSlogans.length
|
||
] ?? {
|
||
text: t.chatPage.defaultSlogan,
|
||
color: "var(--color-ws-fg-primary)",
|
||
};
|
||
const tickerCharacterList = useMemo(() => {
|
||
const seen = new Set<string>();
|
||
const uniqueChars: string[] = [];
|
||
|
||
for (const slogan of motivationSlogans) {
|
||
for (const char of slogan.text) {
|
||
if (seen.has(char)) continue;
|
||
seen.add(char);
|
||
uniqueChars.push(char);
|
||
}
|
||
}
|
||
|
||
return uniqueChars.join("");
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (motivationSlogans.length <= 1) return;
|
||
|
||
const timer = window.setInterval(
|
||
() => {
|
||
setSloganIndex((prev) => (prev + 1) % motivationSlogans.length);
|
||
},
|
||
10 * 60 * 1000,
|
||
);
|
||
|
||
return () => window.clearInterval(timer);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isNewThread) {
|
||
warnedMissingThreadIdRef.current = false;
|
||
return;
|
||
}
|
||
if (!safeThreadId) {
|
||
if (!warnedMissingThreadIdRef.current) {
|
||
warnedMissingThreadIdRef.current = true;
|
||
toast.error(t.chatPage.missingThreadIdForCreate);
|
||
}
|
||
return;
|
||
}
|
||
warnedMissingThreadIdRef.current = false;
|
||
if (initializedThreadRef.current === safeThreadId) return;
|
||
initializedThreadRef.current = safeThreadId;
|
||
void apiClient.threads
|
||
// TODO: 先注释先删除再创建的逻辑
|
||
// .delete(safeThreadId)
|
||
// .catch(() => undefined)
|
||
// .then(() =>
|
||
// apiClient.threads.create({
|
||
// threadId: safeThreadId,
|
||
// ifExists: "raise",
|
||
// }),
|
||
// )
|
||
.create({
|
||
threadId: safeThreadId,
|
||
ifExists: "do_nothing",
|
||
})
|
||
.catch(() => {
|
||
initializedThreadRef.current = null;
|
||
toast.error(t.chatPage.createSessionFailed);
|
||
});
|
||
}, [
|
||
apiClient,
|
||
isNewThread,
|
||
safeThreadId,
|
||
t.chatPage.createSessionFailed,
|
||
t.chatPage.missingThreadIdForCreate,
|
||
]);
|
||
|
||
// 监听宿主页 selectedSkill 消息
|
||
const {
|
||
skillError: selectedSkillError,
|
||
clearSkillError: clearSelectedSkillError,
|
||
isBootstrapping: isSelectedSkillBootstrapping,
|
||
} = useSelectedSkillListener({ threadId: safeThreadId ?? 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));
|
||
},
|
||
onFinish: (state) => {
|
||
if (document.hidden || !document.hasFocus()) {
|
||
let body = t.chatPage.conversationFinished;
|
||
const lastMessage = state.messages.at(-1);
|
||
if (lastMessage) {
|
||
const textContent = textOfMessage(lastMessage);
|
||
if (textContent) {
|
||
body =
|
||
textContent.length > 200
|
||
? textContent.substring(0, 200) + "..."
|
||
: textContent;
|
||
}
|
||
}
|
||
showNotification(state.title, { body });
|
||
}
|
||
},
|
||
});
|
||
|
||
const title = useMemo(() => {
|
||
const result = thread.values?.title ?? "";
|
||
return result === "Untitled" ? "" : result;
|
||
}, [thread.values?.title]);
|
||
|
||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (shouldRenderHistory) {
|
||
setHistoryCutoff(null);
|
||
return;
|
||
}
|
||
if (hasSubmitted) return;
|
||
// Welcome 态下、未提交前,把当前已有消息都当作“历史”切掉。
|
||
// 这样即使历史消息是后续异步补齐,也不会重新露出。
|
||
setHistoryCutoff((prev) => {
|
||
const next = thread.messages.length;
|
||
if (prev === null) return next;
|
||
return next > prev ? next : prev;
|
||
});
|
||
}, [
|
||
hasSubmitted,
|
||
historyCutoff,
|
||
shouldRenderHistory,
|
||
thread.isThreadLoading,
|
||
thread.messages.length,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const pageTitle = isNewThread
|
||
? t.pages.newChat
|
||
: thread.values?.title && thread.values.title !== "Untitled"
|
||
? thread.values.title
|
||
: t.pages.untitled;
|
||
if (thread.isThreadLoading) {
|
||
document.title = `${t.common.loading} - ${t.pages.appName}`;
|
||
} else {
|
||
document.title = `${pageTitle} - ${t.pages.appName}`;
|
||
}
|
||
}, [
|
||
isNewThread,
|
||
t.common.loading,
|
||
t.pages.newChat,
|
||
t.pages.untitled,
|
||
t.pages.appName,
|
||
thread.values?.title,
|
||
thread.isThreadLoading,
|
||
]);
|
||
|
||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||
useEffect(() => {
|
||
setArtifacts(thread.values.artifacts);
|
||
if (
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||
autoSelectFirstArtifact
|
||
) {
|
||
if (thread?.values?.artifacts?.length > 0) {
|
||
setAutoSelectFirstArtifact(false);
|
||
selectArtifact(thread.values.artifacts[0]!);
|
||
}
|
||
}
|
||
}, [
|
||
autoSelectFirstArtifact,
|
||
selectArtifact,
|
||
setArtifacts,
|
||
thread.values.artifacts,
|
||
]);
|
||
|
||
const artifactPanelOpen = useMemo(() => {
|
||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||
return artifactsOpen && artifacts?.length > 0;
|
||
}
|
||
return artifactsOpen;
|
||
}, [artifactsOpen, artifacts]);
|
||
|
||
const todoListCollapsed = true;
|
||
const [showExitDialog, setShowExitDialog] = useState(false);
|
||
const isStreaming = isUploading || thread.isLoading;
|
||
const handleSubmit = useCallback(
|
||
(message: Parameters<typeof sendMessage>[1]) => {
|
||
if (isSelectedSkillBootstrapping) {
|
||
return;
|
||
}
|
||
if (isNewThread && !safeThreadId) {
|
||
toast.error(t.chatPage.missingThreadIdForSend);
|
||
return;
|
||
}
|
||
setHasSubmitted(true);
|
||
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
|
||
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
|
||
}
|
||
void sendMessage(safeThreadId, message);
|
||
},
|
||
[
|
||
isNewThread,
|
||
isSelectedSkillBootstrapping,
|
||
router,
|
||
safeThreadId,
|
||
sendMessage,
|
||
showWelcomeStyle,
|
||
t.chatPage.missingThreadIdForSend,
|
||
],
|
||
);
|
||
const handleStop = useCallback(async () => {
|
||
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 }}>
|
||
<div
|
||
className={cn(
|
||
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
|
||
artifactsOpen ? "w-full" : "w-[70%]",
|
||
)}
|
||
>
|
||
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
|
||
<div
|
||
className={cn(
|
||
"relative overflow-hidden rounded-t-[20px] transition-all duration-300 ease-in-out",
|
||
artifactPanelOpen ? "w-[50%]" : "w-full",
|
||
fullscreen && "hidden",
|
||
)}
|
||
>
|
||
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
|
||
<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" : "",
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-start overflow-hidden text-sm font-medium">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="px-[10px] py-[5px] text-sm font-medium text-ws-base-1 hover:text-ws-base-1/80"
|
||
disabled={isStreaming}
|
||
onClick={() => setShowExitDialog(true)}
|
||
>
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 20 20"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
|
||
className="text-ws-text-muted"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</Button>
|
||
</div>
|
||
<div
|
||
className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-ws-fg-primary"
|
||
style={{
|
||
color: currentSlogan.color,
|
||
}}
|
||
>
|
||
{/* threadTitle={title} */}
|
||
{title !== "Untitled" && (
|
||
// <ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
|
||
<Ticker
|
||
value={currentSlogan.text}
|
||
duration={800}
|
||
easing="easeInOut"
|
||
charWidth={1}
|
||
characterLists={tickerCharacterList}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
||
{/* 取消TodoList */}
|
||
{/* <DevTodoList
|
||
className="bg-ws-surface-base"
|
||
todos={thread.values.todos ?? []}
|
||
hidden={
|
||
!thread.values.todos || thread.values.todos.length === 0
|
||
}
|
||
trigger={
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-full px-[10px] py-[5px] text-sm font-medium text-ws-base-1 hover:text-ws-base-1"
|
||
>
|
||
<ListTodoIcon className="size-4" /> To-dos
|
||
</Button>
|
||
}
|
||
/> */}
|
||
|
||
{artifacts?.length > 0 && !artifactsOpen && (
|
||
<Tooltip content={t.chatPage.viewArtifactsTooltip}>
|
||
<Button
|
||
data-testid="artifacts-open-button"
|
||
className="text-ws-base-1 hover:text-ws-base-1/80"
|
||
variant="ghost"
|
||
onClick={() => {
|
||
setArtifactsOpen(true);
|
||
setSidebarOpen(false);
|
||
}}
|
||
>
|
||
<FilesIcon />
|
||
{t.common.artifacts}
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<main
|
||
className={cn(
|
||
"flex min-h-0 max-w-full grow flex-col",
|
||
showWelcomeStyle && !hasSubmitted
|
||
? "bg-ws-surface-base"
|
||
: "bg-background",
|
||
)}
|
||
>
|
||
<div className="flex size-full justify-center">
|
||
<MessageList
|
||
className={cn(
|
||
"size-full",
|
||
(!showWelcomeStyle || hasSubmitted) && "pt-[58px]",
|
||
)}
|
||
threadId={threadId}
|
||
thread={thread}
|
||
messagesOverride={
|
||
shouldRenderHistory
|
||
? undefined
|
||
: historyCutoff === null
|
||
? []
|
||
: thread.messages.slice(historyCutoff)
|
||
}
|
||
paddingBottom={todoListCollapsed ? 160 : 280}
|
||
showScrollToBottomButton={!showWelcomeStyle}
|
||
scrollButtonClassName="bottom-[112px]"
|
||
/>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"bg-background ml-[20px] rounded-t-[20px] transition-all duration-300 ease-in-out",
|
||
!artifactsOpen && "opacity-0",
|
||
artifactPanelOpen
|
||
? fullscreen
|
||
? "ml-0 w-full"
|
||
: "w-[50%]"
|
||
: "w-0",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"h-full w-full transition-transform duration-300 ease-in-out",
|
||
showWelcomeStyle && !hasSubmitted ? "translate-x-0" : "",
|
||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||
)}
|
||
>
|
||
{selectedArtifact ? (
|
||
<ArtifactFileDetail
|
||
className="size-full"
|
||
filepath={selectedArtifact}
|
||
threadId={threadId}
|
||
/>
|
||
) : (
|
||
<div className="relative flex size-full justify-center px-[20px]">
|
||
<div className="z-30"></div>
|
||
{thread.values.artifacts?.length === 0 ? (
|
||
<ConversationEmptyState
|
||
icon={<FilesIcon />}
|
||
title={t.chatPage.noArtifactSelectedTitle}
|
||
description={t.chatPage.noArtifactSelectedDescription}
|
||
/>
|
||
) : (
|
||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center">
|
||
<header className="flex shrink-0 items-center justify-between border-b">
|
||
<h2 className="h-[58px] text-sm leading-[58px] font-bold text-ws-fg-primary">
|
||
<span>{t.common.artifacts}</span>
|
||
</h2>
|
||
<Button
|
||
data-testid="artifacts-panel-close"
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
onClick={() => {
|
||
setArtifactsOpen(false);
|
||
}}
|
||
>
|
||
<XIcon />
|
||
</Button>
|
||
</header>
|
||
<main className="min-h-0 grow overflow-auto">
|
||
<ArtifactFileList
|
||
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
|
||
files={thread.values.artifacts ?? []}
|
||
threadId={threadId}
|
||
/>
|
||
</main>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Fixed 底部居中输入框容器 */}
|
||
<div
|
||
className={cn(
|
||
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4",
|
||
"transition-all duration-300 ease-in-out",
|
||
fullscreen ? "hidden" : "",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"pointer-events-auto relative w-full max-w-[720px]",
|
||
showWelcomeStyle &&
|
||
!hasSubmitted &&
|
||
"-translate-y-[calc(50vh-96px)]",
|
||
)}
|
||
>
|
||
{!(showWelcomeStyle && thread.isThreadLoading) ? (
|
||
<>
|
||
<InputBox
|
||
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")}
|
||
threadId={threadId}
|
||
showWelcomeStyle={showWelcomeStyle}
|
||
hasSubmitted={hasSubmitted}
|
||
autoFocus={showWelcomeStyle}
|
||
status={
|
||
thread.error
|
||
? "error"
|
||
: isUploading || thread.isLoading
|
||
? "streaming"
|
||
: "ready"
|
||
}
|
||
context={settings.context}
|
||
extraHeader={
|
||
<div className="flex flex-col gap-4">
|
||
{showWelcomeStyle && !hasSubmitted && (
|
||
<Welcome mode={settings.context.mode} />
|
||
)}
|
||
</div>
|
||
}
|
||
disabled={
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||
isSelectedSkillBootstrapping ||
|
||
isUploading ||
|
||
(isNewThread && !safeThreadId)
|
||
}
|
||
onContextChange={(context) => setSettings("context", context)}
|
||
onSubmit={handleSubmit}
|
||
onStop={handleStop}
|
||
/>
|
||
</>
|
||
) : (
|
||
// <InputBoxSkeleton />
|
||
""
|
||
)}
|
||
|
||
{/* {isSelectedSkillBootstrapping && (
|
||
<div className="text-muted-foreground w-full translate-y-8 text-center text-xs">
|
||
正在初始化 Skill 文件...
|
||
</div>
|
||
)} */}
|
||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||
{t.common.notAvailableInDemoMode}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 退出确认对话框 */}
|
||
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}>
|
||
<DevDialogContent>
|
||
<DevDialogHeader>
|
||
<DevDialogTitle>{t.chatPage.exitDialogTitle}</DevDialogTitle>
|
||
</DevDialogHeader>
|
||
<p className="text-muted-foreground text-sm">
|
||
{t.chatPage.exitDialogDescription}
|
||
</p>
|
||
<DevDialogFooter>
|
||
<Button
|
||
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
|
||
variant="ghost"
|
||
onClick={() => setShowExitDialog(false)}
|
||
>
|
||
{t.common.cancel}
|
||
</Button>
|
||
<Button
|
||
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
|
||
variant="ghost"
|
||
onClick={async () => {
|
||
// 如果正在生成,先终止再退出
|
||
if (thread.isLoading) {
|
||
await handleStop();
|
||
}
|
||
setShowExitDialog(false);
|
||
sendToParent({
|
||
type: POST_MESSAGE_TYPES.IS_CHATTING,
|
||
isChatting: false,
|
||
});
|
||
resetNewSessionState();
|
||
// 始终复用 query 中的 thread_id。
|
||
const nextQuery = new URLSearchParams();
|
||
if (threadId && threadId !== "new") {
|
||
nextQuery.set("thread_id", threadId);
|
||
}
|
||
router.replace(
|
||
`/workspace/chats/${threadId}?is_chatting=false`,
|
||
);
|
||
}}
|
||
>
|
||
{t.chatPage.exitDialogConfirm}
|
||
</Button>
|
||
</DevDialogFooter>
|
||
</DevDialogContent>
|
||
</DevDialog>
|
||
|
||
{/* selectedSkill 失败:错误弹窗 */}
|
||
<DevDialog
|
||
open={!!selectedSkillError}
|
||
onOpenChange={(open) => {
|
||
if (!open) clearSelectedSkillError();
|
||
}}
|
||
>
|
||
<DevDialogContent>
|
||
<DevDialogHeader>
|
||
<DevDialogTitle>
|
||
⚠️{" "}
|
||
{selectedSkillError?.title ??
|
||
t.chatPage.selectedSkillLoadFailed}
|
||
</DevDialogTitle>
|
||
</DevDialogHeader>
|
||
<p className="text-muted-foreground text-sm">
|
||
{selectedSkillError?.message ?? t.chatPage.unknownErrorRetry}
|
||
</p>
|
||
<DevDialogFooter singleColumn>
|
||
<Button
|
||
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
|
||
variant="ghost"
|
||
onClick={clearSelectedSkillError}
|
||
>
|
||
{t.common.close}
|
||
</Button>
|
||
</DevDialogFooter>
|
||
</DevDialogContent>
|
||
</DevDialog>
|
||
|
||
{/* MARK: 开发测试:iframe 通信功能测试面板 */}
|
||
{/* {process.env.NODE_ENV !== "production" && <IframeTestPanel />} */}
|
||
</div>
|
||
</ThreadContext.Provider>
|
||
);
|
||
}
|